Compare commits

...

49 Commits

Author SHA1 Message Date
c6e0461857 improve date time range picker in search screen and add monochrome icons. (#219) 2023-05-26 21:33:43 -08:00
30ca356dc8 add date filter shortcuts. (#218) 2023-05-26 13:44:50 -08:00
7d11398e6d fix comment tile. (#215) 2023-05-19 12:37:21 -07:00
a4f52284ef bump flutter version. (#214) 2023-05-18 17:00:47 -07:00
c7d1a42d5a add View root button. (#212) 2023-04-16 22:55:23 -07:00
f83fd66bcc fix onboarding flow. (#211) 2023-04-16 20:21:48 -07:00
c2ec3647e2 bump Flutter version and add Noto Serif font. (#210) 2023-04-16 19:42:18 -07:00
ba63852b7d update tablet view. (#207) 2023-04-11 21:12:48 -07:00
438041183c fix in app review for android. (#206) 2023-04-11 19:09:35 -07:00
114540edd7 cleanup. (#205) 2023-04-11 18:30:03 -07:00
588b3e9508 fix reply box. (#204) 2023-04-11 13:23:11 -07:00
2f0376f8f8 add shortcuts to jump to previous or next root level comment. (#203) 2023-04-11 00:05:56 -07:00
ab4051c018 remove scroll bar. (#202) 2023-04-09 19:47:30 -07:00
c230c21218 update scrollbar. (#201) 2023-04-09 18:35:40 -07:00
c24e12237e fix status bar for android. (#200) 2023-04-09 17:55:29 -07:00
e15dcba93b update dev link. (#197) 2023-04-06 12:24:41 -07:00
1362b93a74 update pubspec file. (#196) 2023-04-06 11:07:02 -07:00
ac18793f98 bump flutter version. (#195) 2023-04-06 09:54:21 -07:00
e52f65c773 update tap target of story tile. (#194) 2023-04-04 15:31:50 -07:00
06212a0d72 fix auth for user with no activity. (#191) 2023-04-01 10:06:32 -07:00
e77c0e3e73 update bottom sheet. (#190) 2023-03-31 23:15:53 -07:00
cb6f41ec49 add keyword filter. (#189) 2023-03-31 13:59:12 -07:00
ab1e90ccad fix comment tile and bottom navigation bar. (#187) 2023-03-26 19:16:38 -07:00
0ca3e96d91 update story tile. (#183) 2023-03-02 18:36:23 -08:00
d1c8eed3de bump flutter version. (#182) 2023-03-02 00:29:43 -08:00
aa6a2c684c bugfixes. (#181) 2023-03-01 12:24:16 -08:00
d4778d9530 remove bottom padding. (#178) 2023-02-28 15:29:03 -08:00
c702e08481 allow exporting favorites to clipboard. (#177) 2023-02-28 14:54:51 -08:00
2af10391bc update story tile. (#175) 2023-02-27 16:48:53 -08:00
c420dd3ca4 correct spelling. (#174) 2023-02-27 15:16:10 -08:00
da7d0757cd add link to privacy policy. (#173) 2023-02-27 14:48:49 -08:00
32ae2087bc fix link button. (#171) 2023-02-26 23:03:48 -08:00
0b5329d050 bugfixes. (#170) 2023-02-26 15:08:18 -08:00
c375def289 bugfixes. (#169) 2023-02-26 12:12:11 -08:00
3469543c7b update action menu. (#168) 2023-02-26 02:40:11 -08:00
ab755581fd add favorite to action menu. (#167) 2023-02-25 23:16:55 -08:00
6b75eb8549 bump version. (#165) 2023-02-24 11:41:19 -08:00
36ded8a8e3 improve search experience. (#164) 2023-02-24 10:38:10 -08:00
582ac7b0be fix push notification. (#161) 2023-02-23 23:14:06 -08:00
e5e3391785 bump flutter version. (#160) 2023-02-23 01:04:44 -08:00
9159fe0fe1 add font customization. (#159) 2023-02-22 15:54:01 -08:00
7c51bad35e add shortcuts for wikipedia and wiktionary. (#157) 2023-02-22 13:37:32 -08:00
6836138d11 fix quote rendering. (#158) 2023-02-22 11:30:33 -08:00
2f71964277 linkifier cleanup. (#156) 2023-02-22 00:15:52 -08:00
c24c5c1b7a add formatting support (#155) 2023-02-21 23:40:25 -08:00
755b112382 remove isFirstLaunch val. (#153) 2023-02-12 20:22:38 -08:00
d44b64d249 fix feature discovery. (#152) 2023-02-12 19:39:51 -08:00
35ed917e66 improve onboarding experience. (#151) 2023-02-12 18:49:17 -08:00
15b75ef37c cleanup. (#149) 2023-02-11 19:44:54 -08:00
164 changed files with 5753 additions and 2628 deletions

View File

@ -1,4 +1,4 @@
include: package:very_good_analysis/analysis_options.3.1.0.yaml include: package:very_good_analysis/analysis_options.5.0.0.yaml
linter: linter:
rules: rules:
parameter_assignments: false parameter_assignments: false

View File

@ -64,12 +64,15 @@ android {
storePassword keystoreProperties['storePassword'] storePassword keystoreProperties['storePassword']
} }
} }
buildTypes { buildTypes {
release { release {
signingConfig signingConfigs.release signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
} }
} }
} }
flutter { flutter {

View File

@ -37,15 +37,6 @@
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
/> />
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>

View File

@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_adaptive_back"/> <background android:drawable="@mipmap/ic_launcher_adaptive_back"/>
<foreground android:drawable="@mipmap/ic_launcher_adaptive_fore"/> <foreground android:drawable="@mipmap/ic_launcher_adaptive_fore"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -24,6 +24,6 @@ subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
task clean(type: Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

18
assets/eula.md Normal file
View File

@ -0,0 +1,18 @@
## End-user License Agreement
This policy applies to the usage of the Hacki app.
Please read this Mobile Application End User License Agreement (“EULA”) carefully before using the Hacki mobile application ("Mobile App"), which allows You to read and contribute to Hacker News from Your mobile device. This EULA forms a binding legal agreement between you (and any other entity on whose behalf you accept these terms) (collectively “You” or “Your”) and Hacki (each separately a “Party” and collectively the “Parties”) as of the date you download the Mobile App. Your use of the Mobile App is subject to this EULA.
### Changes to this EULA
Hacki reserves the right to modify this EULA at any time and for any reason. You are responsible for complying with the updated EULA. Your continued use of the Mobile App indicates Your consent to the updated terms.
### No Included Maintenance and Support
Hacki may deploy changes, updates, or enhancements to the Mobile App at any time. Hacki may provide maintenance and support for the Mobile App, but has no obligation whatsoever to furnish such services to You and may terminate such services at any time without notice.
### No Warranty
Hacki expressly disclaims all warranties of any kind, whether express or implied.
The Mobile App is only available for supported devices and might not work on every device. Determining whether Your device is a supported or compatible device for use of the Mobile App is solely Your responsibility, and downloading the Mobile App is done at Your own risk. Smartsheet does not represent or warrant that the Mobile App and Your device are compatible or that the Mobile App will work on Your device.
### Your Consent
By using the app, you consent to the end-user license agreement.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

48
assets/privacy_policy.md Normal file
View File

@ -0,0 +1,48 @@
## Privacy Policy
This policy applies to all information collected or submitted on Hacki.
### Information we collect
Hacki collects anonymous statistics such as crash reports and feature usage. These data are solely used to track app's health and are only stored locally on your device and only got sent to us when you choose to do so.
### Ads and analytics
Hacki does not serve ads.
Hacki collects aggregate, anonymous statistics to improve the app but these data are only stored locally on your device and only got sent to us when you choose to do so.
### Information usage
We use the information we collect to operate and improve our website, apps, and customer support.
We do not share personal information with outside parties except to the extent necessary to accomplish Hackis functionality.
We may disclose your information in response to subpoenas, court orders, or other legal requirements; to exercise our legal rights or defend against legal claims; to investigate, prevent, or take action regarding illegal activities, suspected fraud or abuse, violations of our policies; or to protect our rights and property.
### Security
Hacki uses the official Hacker News API for fetching data from Hacker News.
When logging in, usernames and passwords are securely sent to Hacker News' servers for authentication.
### Third-party links and content
Hacki displays links and content from third-party websites. These websites have their own independent privacy policies, and we have no responsibility or liability for their content or activities.
#### California Online Privacy Protection Act Compliance
Hacki complies with the California Online Privacy Protection Act. We therefore will not distribute your personal information to outside parties without your consent.
#### Childrens Online Privacy Protection Act Compliance
Hacki never collects or maintain information at our website from those we actually know are under 13, and no part of our website is structured to attract anyone under 13.
#### Information for European Union Customers
By using Hacki and providing your information, you authorize us to collect, use, and store your information outside of the European Union.
#### International Transfers of Information
Information may be processed, stored, and used outside of the country in which you are located. Data privacy laws vary across jurisdictions, and different laws may be applicable to your data depending on where it is processed, stored, or used.
### Your Consent
By using the app, you consent to the privacy policy.
### Contacting Us
If you have questions regarding this privacy policy, you may e-mail me us at jfeng@fastmail.com.
### Changes to this policy
If we decide to change this privacy policy, we will post those changes on this page.
February 27, 2023: First published.

30
components/in_app_review/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/

View File

@ -0,0 +1,39 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
version:
revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
channel: stable
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
base_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
- platform: android
create_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
base_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
- platform: ios
create_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
base_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
- platform: macos
create_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
base_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
- platform: windows
create_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
base_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@ -0,0 +1,102 @@
# [2.0.6]
- Update Android Play Core dependency to Play Review 2.0.1.
# [2.0.5]
- Migrate Android Play Core dependency to Play Review 2.0.0.
- Recreate the example app.
- Update in_app_review_platform_interface to 2.0.4
# [2.0.4]
- Migrate maven repository from jcenter to mavenCentral
- `isAvailable()` now returns `false` on web.
# [2.0.3]
- Fix iOS no-scene exception. ([#41](https://github.com/britannio/in_app_review/issues/41))
# [2.0.2]
- Replace iOS Swift code with Objective-C to add compatibility with Objective-C Flutter apps.
# [2.0.1]
- Fix rare null pointer exception on Android
- Fix MissingPluginException on MacOS
- Bump the minimum Dart SDK version from `2.12.0-0` to `2.12.0`.
- Bump the minimum Flutter version to `2.0.0`.
- Update in_app_review_platform_interface to 2.0.2
# [2.0.0]
- Migrate to null safety.
# [1.0.4]
- Update in_app_review_platform_interface to 1.0.5
- Remove dependency on `package_info`.
- Handle `openStoreListing()` with native code for Android, iOS and MacOS.
# [1.0.3]
- Update in_app_review_platform_interface to 1.0.4
- Update android compileSdkVersion to 29.
- Lower dependency version constraints.
# [1.0.2]
- Update in_app_review_platform_interface to 1.0.3
- Open the App Store directly instead of via the Safari View Controller.
- Add automated tests.
- Improve docs.
# [1.0.1+1]
- Update in_app_review_platform_interface to 1.0.2
# [1.0.0]
- Migrate to use `in_app_review_platform_interface`.
- Add Windows support for `openStoreListing`.
# [0.2.1+1]
- Improve iOS testing docs.
# [0.2.1]
- Update dependencies.
- Android Play Core Library V1.8.2 release notes:
- Fixed UI flickering in the In-App Review API
# [0.2.0+4]
- Remove deprecated API warning.
- Update dependencies.
# [0.2.0+3]
- Instructions in the README have been improved along with the example.
# [0.2.0+2]
- Update changelog format
# [0.2.0+1]
- Update MacOS testing instructions
# [0.2.0] Breaking Change
- Add MacOS support
- Rename `openStoreListing(iOSAppStoreId: '')` to `openStoreListing(appStoreId: '')`
# [0.1.0]
- Improve docs
- Set Android minSdkVersion to 16
- Refactor Android Plugin
# [0.0.1]
Initial release

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Britannio Jarrett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

View File

@ -0,0 +1,49 @@
group 'dev.britannio.in_app_review'
version '1.0-SNAPSHOT'
buildscript {
ext.kotlin_version = '1.6.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 31
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
minSdkVersion 16
}
dependencies {
}
}

View File

@ -0,0 +1 @@
rootProject.name = 'in_app_review'

View File

@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="dev.britannio.in_app_review">
</manifest>

View File

@ -0,0 +1,59 @@
package dev.britannio.in_app_review;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import androidx.annotation.NonNull;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.embedding.engine.plugins.activity.ActivityAware;
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
/**
* InAppReviewPlugin
*/
public class InAppReviewPlugin implements FlutterPlugin, MethodCallHandler {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
private MethodChannel channel;
private final String TAG = "InAppReviewPlugin";
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "dev.britannio.in_app_review");
channel.setMethodCallHandler(this);
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
Log.i(TAG, "onMethodCall: " + call.method);
switch (call.method) {
case "isAvailable":
case "requestReview":
case "openStoreListing":
default:
result.notImplemented();
break;
}
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
channel.setMethodCallHandler(null);
}
}

38
components/in_app_review/ios/.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
.idea/
.vagrant/
.sconsign.dblite
.svn/
.DS_Store
*.swp
profile
DerivedData/
build/
GeneratedPluginRegistrant.h
GeneratedPluginRegistrant.m
.generated/
*.pbxuser
*.mode1v3
*.mode2v3
*.perspectivev3
!default.pbxuser
!default.mode1v3
!default.mode2v3
!default.perspectivev3
xcuserdata
*.moved-aside
*.pyc
*sync/
Icon?
.tags*
/Flutter/Generated.xcconfig
/Flutter/ephemeral/
/Flutter/flutter_export_environment.sh

View File

@ -0,0 +1,4 @@
#import <Flutter/Flutter.h>
@interface InAppReviewPlugin : NSObject<FlutterPlugin>
@end

View File

@ -0,0 +1,107 @@
#import "InAppReviewPlugin.h"
@import StoreKit;
@implementation InAppReviewPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"dev.britannio.in_app_review" binaryMessenger:[registrar messenger]];
InAppReviewPlugin* instance = [[InAppReviewPlugin alloc] init];
[registrar addMethodCallDelegate:instance channel:channel];
}
- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result {
[self logMessage:@"handle" details:call.method];
if ([call.method isEqual:@"requestReview"]) {
[self requestReview:result];
} else if ([call.method isEqual:@"isAvailable"]) {
[self isAvailable:result];
} else if ([call.method isEqual:@"openStoreListing"]) {
[self openStoreListingWithStoreId:call.arguments result:result];
} else {
[self logMessage:@"method not implemented"];
result(FlutterMethodNotImplemented);
}
}
- (void) requestReview:(FlutterResult)result {
if (@available(iOS 14, *)) {
[self logMessage:@"iOS 14+"];
UIWindowScene *scene = [self findActiveScene];
[SKStoreReviewController requestReviewInScene:scene];
result(nil);
} else if (@available(iOS 10.3, *)) {
[self logMessage:@"iOS 10.3+"];
[SKStoreReviewController requestReview];
result(nil);
} else {
result([FlutterError errorWithCode:@"unavailable"
message:@"In-App Review unavailable"
details:nil]);
}
}
- (UIWindowScene *) findActiveScene API_AVAILABLE(ios(13.0)){
for (UIWindowScene *scene in UIApplication.sharedApplication.connectedScenes) {
if (scene.activationState == UISceneActivationStateForegroundActive) {
return scene;
}
}
return nil;
}
- (void) isAvailable:(FlutterResult)result {
if (@available(iOS 10.3, *)) {
[self logMessage:@"available"];
result(@YES);
} else {
[self logMessage:@"unavailable"];
result(@NO);
}
}
- (void) openStoreListingWithStoreId:(NSString *)storeId result:(FlutterResult)result {
if (!storeId) {
result([FlutterError errorWithCode:@"no-store-id"
message:@"Your store id must be passed as the method channel's argument"
details:nil]);
return;
}
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"https://apps.apple.com/app/id%@?action=write-review", storeId]];
if (!url) {
result([FlutterError errorWithCode:@"url-construct-fail"
message:@"Failed to construct url"
details:nil]);
return;
}
UIApplication *app = [UIApplication sharedApplication];
if (@available(iOS 10.0, *)) {
[app openURL:url options:@{} completionHandler:nil];
} else {
[app openURL:url];
}
}
#pragma mark - Logging Helpers
- (void) logMessage:(NSString *) message {
NSLog(@"InAppReviewPlugin: %@", message);
}
- (void) logMessage:(NSString *) message
details:(NSString *) details {
NSLog(@"InAppReviewPlugin: %@ %@", message, details);
}
@end

View File

@ -0,0 +1,23 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint in_app_review.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'in_app_review'
s.version = '0.2.0'
s.summary = 'Flutter plugin for showing the In-App Review/System Rating pop up.'
s.description = <<-DESC
Flutter plugin for showing the In-App Review/System Rating pop up..
DESC
s.homepage = 'http://example.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Britannio Jarrett' => 'britanniojarrett@gmail.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'Flutter'
s.platform = :ios, '9.0'
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
s.swift_version = '5.0'
end

View File

@ -0,0 +1,50 @@
import 'dart:async';
import 'package:in_app_review_platform_interface/in_app_review_platform_interface.dart';
class InAppReview {
InAppReview._();
static final InAppReview instance = InAppReview._();
/// Checks if the device is able to show a review dialog.
///
/// On Android the Google Play Store must be installed and the device must be
/// running **Android 5 Lollipop(API 21)** or higher.
///
/// iOS devices must be running **iOS version 10.3** or higher.
///
/// MacOS devices must be running **MacOS version 10.14** or higher
Future<bool> isAvailable() => InAppReviewPlatform.instance.isAvailable();
/// Attempts to show the review dialog. It's recommended to first check if
/// the device supports this feature via [isAvailable].
///
/// To improve the users experience, iOS and Android enforce limitations
/// that might prevent this from working after a few tries. iOS & MacOS users
/// can also disable this feature entirely in the App Store settings.
///
/// More info and guidance:
/// https://developer.android.com/guide/playcore/in-app-review#when-to-request
/// https://developer.apple.com/design/human-interface-guidelines/ios/system-capabilities/ratings-and-reviews/
/// https://developer.apple.com/design/human-interface-guidelines/macos/system-capabilities/ratings-and-reviews/
Future<void> requestReview() => InAppReviewPlatform.instance.requestReview();
/// Opens the Play Store on Android, the App Store with a review
/// screen on iOS & MacOS and the Microsoft Store on Windows.
///
/// [appStoreId] is required for iOS & MacOS.
///
/// [microsoftStoreId] is required for Windows.
Future<void> openStoreListing({
/// Required for iOS & MacOS.
String? appStoreId,
/// Required for Windows.
String? microsoftStoreId,
}) =>
InAppReviewPlatform.instance.openStoreListing(
appStoreId: appStoreId,
microsoftStoreId: microsoftStoreId,
);
}

View File

@ -0,0 +1,42 @@
import Cocoa
import FlutterMacOS
import StoreKit
public class InAppReviewPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "dev.britannio.in_app_review", binaryMessenger: registrar.messenger)
let instance = InAppReviewPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "requestReview":
//App Store Review
if #available(OSX 10.14, *) {
SKStoreReviewController.requestReview()
result(nil)
} else {
result(FlutterError(code: "unavailable", message: "In-App Review unavailable", details: nil))
}
case "isAvailable":
if #available(OSX 10.14, *) {
result(true)
} else {
result(false)
}
case "openStoreListing":
let storeId : String = call.arguments as! String;
guard let writeReviewURL = URL(string: "macappstore://apps.apple.com/app/id" + storeId + "?action=write-review")
else {
result(FlutterError(code: "url_construct_fail", message: "Failed to construct url", details: nil))
return
}
NSWorkspace.shared.open(writeReviewURL)
result(nil);
default:
result(FlutterMethodNotImplemented)
}
}
}

View File

@ -0,0 +1,22 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint in_app_review.podspec' to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'in_app_review'
s.version = '0.2.0'
s.summary = 'Flutter plugin for showing the In-App Review/System Rating pop up.'
s.description = <<-DESC
Flutter plugin for showing the In-App Review/System Rating pop up.
DESC
s.homepage = 'https://github.com/britannio/in_app_review'
s.license = { :file => '../LICENSE' }
s.author = { 'Britannio Jarrett' => 'britanniojarrett@gmail.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'FlutterMacOS'
s.platform = :osx, '10.11'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
s.swift_version = '5.0'
end

View File

@ -0,0 +1,46 @@
name: in_app_review
description: Flutter plugin for showing the In-App Review/System Rating pop up on Android, iOS and MacOS. It makes it easy for users to rate your app.
version: 2.0.6
homepage: https://github.com/britannio/in_app_review/tree/master/in_app_review
environment:
sdk: '>=2.12.0 <3.0.0'
flutter: ">=2.0.0"
dependencies:
flutter:
sdk: flutter
in_app_review_platform_interface: ^2.0.4
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.0.0
plugin_platform_interface: ^2.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# This section identifies this Flutter project as a plugin project.
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.)
# which should be registered in the plugin registry. This is required for
# using method channels.
# The Android 'package' specifies package in which the registered class is.
# This is required for using method channels on Android.
# The 'ffiPlugin' specifies that native code should be built and bundled.
# This is required for using `dart:ffi`.
# All these are used by the tooling to maintain consistency when
# adding or updating assets for this project.
plugin:
platforms:
android:
package: dev.britannio.in_app_review
pluginClass: InAppReviewPlugin
ios:
pluginClass: InAppReviewPlugin
macos:
pluginClass: InAppReviewPlugin

View File

@ -0,0 +1,12 @@
.DS_Store
.dart_tool/
.packages
.pub/
build/
pubspec.lock
.flutter-plugins
.flutter-plugins-dependencies

View File

@ -0,0 +1,46 @@
# [2.0.4]
- Update usage of `pkg:url_launcher` to address deprecations.
# [2.0.3]
- `isAvailable()` now returns `false` on web.
# [2.0.2]
- Bump the minimum Flutter version to `2.0.0`.
# [2.0.1]
- Bump the minimum Dart SDK version from `2.12.0-0` to `2.12.0`.
# [2.0.0]
- Migrate to null safety.
# [1.0.5]
- Remove dependency on `package_info`.
- Handle `openStoreListing()` with native code for Android, iOS and MacOS.
# [1.0.4]
- Lower dependency version constraints
# [1.0.3]
- Open the App Store directly instead of via the Safari View Controller.
- Add automated tests.
# [1.0.2]
- Rename `openStoreListing(windowsStoreId: '')` to `openStoreListing(microsoftStoreId: '')`.
- Update dependencies.
# [1.0.1]
- Remove unnecessary files.
# [1.0.0]
- Initial release.

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Britannio Jarrett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,26 @@
# in_app_review_platform_interface
A common platform interface for the [`in_app_review`][1] plugin.
This interface allows platform-specific implementations of the `in_app_review`
plugin, as well as the plugin itself, to ensure they are supporting the
same interface.
# Usage
To implement a new platform-specific implementation of `in_app_review`, extend
[`InAppReviewPlatform`][2] with an implementation that performs the
platform-specific behavior, and when you register your plugin, set the default
`InAppReviewPlatform` by calling
`InAppReviewPlatform.instance = MyInAppReview()`.
# Note on breaking changes
Strongly prefer non-breaking changes (such as adding a method to the interface)
over breaking changes for this package.
See https://flutter.dev/go/platform-interface-breaking-changes for a discussion
on why a less-clean interface is preferable to a breaking change.
[1]: ../in_app_review
[2]: lib/in_app_review_platform_interface.dart

View File

@ -0,0 +1,72 @@
import 'package:in_app_review_platform_interface/method_channel_in_app_review.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
/// The interface that implementations of in_app_review must implement.
///
/// Platform implementations should extend this class rather than implement it
/// as `in_app_review` does not consider newly added methods to be breaking
/// changes. Extending this class (using `extends`) ensures that the subclass
/// will get the default implementation, while platform implementations that
/// `implements` this interface will be broken by newly added
/// [InAppReviewPlatform] methods.
abstract class InAppReviewPlatform extends PlatformInterface {
InAppReviewPlatform() : super(token: _token);
static InAppReviewPlatform _instance =
MethodChannelInAppReview() as InAppReviewPlatform;
static final Object _token = Object();
static InAppReviewPlatform get instance => _instance;
/// Platform-specific plugins should set this with their own platform-specific
/// class that extends [InAppReviewPlatform] when they register themselves.
static set instance(InAppReviewPlatform instance) {
PlatformInterface.verifyToken(instance, _token);
_instance = instance;
}
/// Checks if the device is able to show a review dialog.
///
/// On Android the Google Play Store must be installed and the device must be
/// running **Android 5 Lollipop(API 21)** or higher.
///
/// iOS devices must be running **iOS version 10.3** or higher.
///
/// MacOS devices must be running **MacOS version 10.14** or higher
Future<bool> isAvailable() {
throw UnimplementedError('isAvailable() has not been implemented.');
}
/// Attempts to show the review dialog. It's recommended to first check if
/// this cannot be done via [isAvailable]. If it is not available then
/// you can open the store listing via [openStoreListing].
///
/// To improve the users experience, iOS and Android enforce limitations
/// that might prevent this from working after a few tries. iOS & MacOS users
/// can also disable this feature entirely in the App Store settings.
///
/// More info and guidance:
/// https://developer.android.com/guide/playcore/in-app-review#when-to-request
/// https://developer.apple.com/design/human-interface-guidelines/ios/system-capabilities/ratings-and-reviews/
/// https://developer.apple.com/design/human-interface-guidelines/macos/system-capabilities/ratings-and-reviews/
Future<void> requestReview() {
throw UnimplementedError('requestReview() has not been implemented.');
}
/// Opens the Play Store on Android, the App Store with a review
/// screen on iOS & MacOS and the Microsoft Store on Windows.
///
/// [appStoreId] is required for iOS & MacOS.
///
/// [microsoftStoreId] is required for Windows.
Future<void> openStoreListing({
/// Required for iOS & MacOS.
String? appStoreId,
/// Required for Windows.
String? microsoftStoreId,
}) {
throw UnimplementedError('openStoreListing() has not been implemented.');
}
}

View File

@ -0,0 +1,65 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:platform/platform.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'in_app_review_platform_interface.dart';
/// An implementation of [InAppReviewPlatform] that uses method channels.
class MethodChannelInAppReview extends InAppReviewPlatform {
MethodChannel _channel = MethodChannel('dev.britannio.in_app_review');
Platform _platform = const LocalPlatform();
@visibleForTesting
set channel(MethodChannel channel) => _channel = channel;
@visibleForTesting
set platform(Platform platform) => _platform = platform;
@override
Future<bool> isAvailable() async {
if (kIsWeb) return false;
return _channel
.invokeMethod<bool>('isAvailable')
.then((bool? available) => available ?? false, onError: (_) => false);
}
@override
Future<void> requestReview() => _channel.invokeMethod('requestReview');
@override
Future<void> openStoreListing({
String? appStoreId,
String? microsoftStoreId,
}) async {
final bool isiOS = _platform.isIOS;
final bool isMacOS = _platform.isMacOS;
final bool isAndroid = _platform.isAndroid;
final bool isWindows = _platform.isWindows;
if (isiOS || isMacOS) {
await _channel.invokeMethod(
'openStoreListing',
ArgumentError.checkNotNull(appStoreId, 'appStoreId'),
);
} else if (isAndroid) {
await _channel.invokeMethod('openStoreListing');
} else if (isWindows) {
ArgumentError.checkNotNull(microsoftStoreId, 'microsoftStoreId');
await _launchUrl(
'ms-windows-store://review/?ProductId=$microsoftStoreId',
);
} else {
throw UnsupportedError(
'Platform(${_platform.operatingSystem}) not supported',
);
}
}
Future<void> _launchUrl(String url) async {
if (!await canLaunchUrlString(url)) return;
await launchUrlString(url, mode: LaunchMode.externalNonBrowserApplication);
}
}

View File

@ -0,0 +1,24 @@
name: in_app_review_platform_interface
description: A common platform interface for the in_app_review plugin.
# NOTE: We strongly prefer non-breaking changes, even at the expense of a
# less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes
version: 2.0.4
homepage: https://github.com/britannio/in_app_review/tree/master/in_app_review_platform_interface
environment:
sdk: '>=2.12.0 <3.0.0'
flutter: ">=2.0.0"
dependencies:
flutter:
sdk: flutter
url_launcher: ^6.1.0
plugin_platform_interface: ^2.0.0
platform: ^3.0.0
dev_dependencies:
flutter_test:
sdk: flutter

View File

@ -0,0 +1,121 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_review_platform_interface/method_channel_in_app_review.dart';
import 'package:platform/platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late MethodChannelInAppReview methodChannelInAppReview;
late List<MethodCall> log = <MethodCall>[];
const MethodChannel channel = MethodChannel('dev.britannio.in_app_review');
setUp(() {
methodChannelInAppReview = MethodChannelInAppReview();
methodChannelInAppReview.channel = channel;
log = <MethodCall>[];
});
tearDown(() {
log.clear();
});
group('isAvailable', () {
test(
'should invoke the isAvailable method channel',
() async {
// ACT
final bool result = await methodChannelInAppReview.isAvailable();
// ASSERT
expect(log, <Matcher>[isMethodCall('isAvailable', arguments: null)]);
expect(result, isTrue);
},
);
});
group('requestReview', () {
test(
'should invoke the requestReview method channel',
() async {
// ACT
await methodChannelInAppReview.requestReview();
// ASSERT
expect(log, <Matcher>[isMethodCall('requestReview', arguments: null)]);
},
);
});
group('openStoreListing', () {
test(
'should invoke the openStoreListing method channel on Android',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'android');
// ACT
await methodChannelInAppReview.openStoreListing();
// ASSERT
expect(
log,
<Matcher>[isMethodCall('openStoreListing', arguments: null)],
);
},
);
test(
'should invoke the openStoreListing method channel on iOS',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'ios');
final String appStoreId = "store_id";
// ACT
await methodChannelInAppReview.openStoreListing(appStoreId: appStoreId);
// ASSERT
expect(log,
<Matcher>[isMethodCall('openStoreListing', arguments: appStoreId)]);
},
);
test(
'should invoke the openStoreListing method channel on MacOS',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'macos');
final String appStoreId = "store_id";
// ACT
await methodChannelInAppReview.openStoreListing(appStoreId: appStoreId);
// ASSERT
expect(log,
<Matcher>[isMethodCall('openStoreListing', arguments: appStoreId)]);
},
);
test(
'should invoke the openStoreListing method channel on Windows',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'windows');
final String microsoftStoreId = 'store_id';
// ACT
await methodChannelInAppReview.openStoreListing(
microsoftStoreId: microsoftStoreId,
);
// ASSERT
expect(log, <Matcher>[
isMethodCall('openStoreListing', arguments: microsoftStoreId)
]);
},
skip:
'The windows uwp implementation still uses the url_launcher package',
);
});
}

View File

@ -0,0 +1 @@
- Navigation shortcuts.

View File

@ -0,0 +1,5 @@
- Customization of tab bar.
- Option to enable swipe gesture for switching between tabs.
- Access to action menu from home screen.
- Access to Wikipedia and Wiktionary from text selection toolbar.
- Quotes and emphasis rendering.

View File

@ -0,0 +1,5 @@
- Customization of tab bar.
- Option to enable swipe gesture for switching between tabs.
- Access to action menu from home screen.
- Access to Wikipedia and Wiktionary from text selection toolbar.
- Quotes and emphasis rendering.

View File

@ -2,6 +2,8 @@ PODS:
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- ReachabilitySwift - ReachabilitySwift
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_email_sender (0.0.1): - flutter_email_sender (0.0.1):
- Flutter - Flutter
@ -21,6 +23,8 @@ PODS:
- FMDB (2.7.5): - FMDB (2.7.5):
- FMDB/standard (= 2.7.5) - FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5) - FMDB/standard (2.7.5)
- in_app_review (0.2.0):
- Flutter
- integration_test (0.0.1): - integration_test (0.0.1):
- Flutter - Flutter
- OrderedSet (5.0.0) - OrderedSet (5.0.0)
@ -37,7 +41,7 @@ PODS:
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- sqflite (0.0.2): - sqflite (0.0.3):
- Flutter - Flutter
- FMDB (>= 2.7.5) - FMDB (>= 2.7.5)
- synced_shared_preferences (0.0.1): - synced_shared_preferences (0.0.1):
@ -53,18 +57,20 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`) - flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`) - flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`) - synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@ -81,6 +87,8 @@ SPEC REPOS:
EXTERNAL SOURCES: EXTERNAL SOURCES:
connectivity_plus: connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios" :path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_email_sender: flutter_email_sender:
@ -93,18 +101,20 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_siri_suggestions: flutter_siri_suggestions:
:path: ".symlinks/plugins/flutter_siri_suggestions/ios" :path: ".symlinks/plugins/flutter_siri_suggestions/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
integration_test: integration_test:
:path: ".symlinks/plugins/integration_test/ios" :path: ".symlinks/plugins/integration_test/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation: path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios" :path: ".symlinks/plugins/path_provider_foundation/darwin"
receive_sharing_intent: receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios" :path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus: share_plus:
:path: ".symlinks/plugins/share_plus/ios" :path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/ios" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/ios" :path: ".symlinks/plugins/sqflite/ios"
synced_shared_preferences: synced_shared_preferences:
@ -119,7 +129,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/workmanager/ios" :path: ".symlinks/plugins/workmanager/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721 flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
@ -127,21 +138,22 @@ SPEC CHECKSUMS:
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5 in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: 13825b8a9334a850581300559b8839134b124670
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7 synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937 PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937
COCOAPODS: 1.11.3 COCOAPODS: 1.12.0

View File

@ -10,6 +10,7 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7A6CD5D595D5F4E8710804C0 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8BF0A917F40A838BF30D8F4C /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@ -22,7 +23,6 @@
E530B1B0283B54DA004E8EB6 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E530B1AE283B54DA004E8EB6 /* MainInterface.storyboard */; }; E530B1B0283B54DA004E8EB6 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E530B1AE283B54DA004E8EB6 /* MainInterface.storyboard */; };
E530B1B4283B54DA004E8EB6 /* Action Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E530B1A6283B54DA004E8EB6 /* Action Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E530B1B4283B54DA004E8EB6 /* Action Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E530B1A6283B54DA004E8EB6 /* Action Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E575B6F027EBC6DA002B1508 /* CloudKit.framework */; }; E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E575B6F027EBC6DA002B1508 /* CloudKit.framework */; };
FC507E94AA7767C155787DB3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -68,14 +68,14 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
027B292CC58CF92F11FC0A69 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 0E63A5CE3FDBCCD054072136 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4449F5D4D39C23F292D07005 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8BF0A917F40A838BF30D8F4C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -83,8 +83,8 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B9EC882BDD04A309C317E416 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
DF5D5FFF325B7D5DFEE88A3F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; D73EA9FA5E6F35364DCA0CD1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
E51D52AD283B464E00FC8DD8 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; E51D52AD283B464E00FC8DD8 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
E51D52AF283B464E00FC8DD8 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; E51D52AF283B464E00FC8DD8 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
E51D52B2283B464E00FC8DD8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; }; E51D52B2283B464E00FC8DD8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
@ -107,7 +107,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */, E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */,
FC507E94AA7767C155787DB3 /* Pods_Runner.framework in Frameworks */, 7A6CD5D595D5F4E8710804C0 /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -183,8 +183,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E575B6F027EBC6DA002B1508 /* CloudKit.framework */, E575B6F027EBC6DA002B1508 /* CloudKit.framework */,
BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */,
E530B1A7283B54DA004E8EB6 /* UniformTypeIdentifiers.framework */, E530B1A7283B54DA004E8EB6 /* UniformTypeIdentifiers.framework */,
8BF0A917F40A838BF30D8F4C /* Pods_Runner.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@ -192,9 +192,9 @@
D79CD63C88FF49EF451AFDDF /* Pods */ = { D79CD63C88FF49EF451AFDDF /* Pods */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DF5D5FFF325B7D5DFEE88A3F /* Pods-Runner.debug.xcconfig */, 0E63A5CE3FDBCCD054072136 /* Pods-Runner.debug.xcconfig */,
4449F5D4D39C23F292D07005 /* Pods-Runner.release.xcconfig */, D73EA9FA5E6F35364DCA0CD1 /* Pods-Runner.release.xcconfig */,
027B292CC58CF92F11FC0A69 /* Pods-Runner.profile.xcconfig */, B9EC882BDD04A309C317E416 /* Pods-Runner.profile.xcconfig */,
); );
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
@ -229,15 +229,15 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
41DC8215F9CFD708C36ECBA8 /* [CP] Check Pods Manifest.lock */, E2E6E097A94005D9196D0A71 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
7714A105B2069B720D0DF18E /* [CP] Embed Pods Frameworks */,
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */, E51D52B8283B464E00FC8DD8 /* Embed App Extensions */,
F1959755D5521D58CA193498 /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -365,6 +365,7 @@
files = ( files = (
); );
inputPaths = ( inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
); );
name = "Thin Binary"; name = "Thin Binary";
outputPaths = ( outputPaths = (
@ -373,7 +374,22 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
41DC8215F9CFD708C36ECBA8 /* [CP] Check Pods Manifest.lock */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
E2E6E097A94005D9196D0A71 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@ -395,7 +411,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
7714A105B2069B720D0DF18E /* [CP] Embed Pods Frameworks */ = { F1959755D5521D58CA193498 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@ -412,21 +428,6 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -565,11 +566,9 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = QMWX3X2NF7;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Hacki; INFOPLIST_KEY_CFBundleDisplayName = Hacki;
@ -583,7 +582,6 @@
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.jiaqi.hacki";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -707,11 +705,9 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = QMWX3X2NF7;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Hacki; INFOPLIST_KEY_CFBundleDisplayName = Hacki;
@ -725,7 +721,6 @@
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.jiaqi.hacki";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -780,11 +775,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements"; CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = QMWX3X2NF7;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_FILE = "Share Extension/Info.plist";
@ -802,7 +795,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension"; PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki share extension profile";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -863,11 +855,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements"; CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = QMWX3X2NF7;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist"; INFOPLIST_FILE = "Share Extension/Info.plist";
@ -884,7 +874,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension"; PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki share extension profile";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -905,11 +894,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements"; CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = QMWX3X2NF7;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist"; INFOPLIST_FILE = "Action Extension/Info.plist";
@ -927,7 +914,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension"; PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki action extension profile";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -992,11 +978,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements"; CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = QMWX3X2NF7;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist"; INFOPLIST_FILE = "Action Extension/Info.plist";
@ -1013,7 +997,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension"; PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki action extension profile";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View File

@ -18,6 +18,8 @@ import flutter_local_notifications
center.delegate = self center.delegate = self
WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!) WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!)
WorkmanagerPlugin.registerTask(withIdentifier: "workmanager.background.task")
if #available(iOS 10.0, *) { if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate

View File

@ -41,19 +41,25 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((bool loggedIn) async { await _authRepository.loggedIn.then((bool loggedIn) async {
if (loggedIn) { if (loggedIn) {
final String? username = await _authRepository.username; final String? username = await _authRepository.username;
final User user = await _storiesRepository.fetchUser(id: username!); User? user = await _storiesRepository.fetchUser(id: username!);
/// According to Hacker News' API documentation,
/// if user has no public activity (posting a comment or story),
/// then it will not be available from the API.
user ??= User.emptyWithId(username);
emit( emit(
state.copyWith( state.copyWith(
isLoggedIn: true, isLoggedIn: true,
user: user, user: user,
status: AuthStatus.loaded,
), ),
); );
} else { } else {
emit( emit(
state.copyWith( state.copyWith(
status: AuthStatus.loaded,
isLoggedIn: false, isLoggedIn: false,
status: AuthStatus.loaded,
), ),
); );
} }
@ -83,10 +89,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
if (successful) { if (successful) {
final User user = await _storiesRepository.fetchUser(id: event.username); final User? user = await _storiesRepository.fetchUser(id: event.username);
emit( emit(
state.copyWith( state.copyWith(
user: user, user: user ?? User.emptyWithId(event.username),
isLoggedIn: true, isLoggedIn: true,
status: AuthStatus.loaded, status: AuthStatus.loaded,
), ),

View File

@ -17,11 +17,13 @@ part 'stories_state.dart';
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> { class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesBloc({ StoriesBloc({
required PreferenceCubit preferenceCubit, required PreferenceCubit preferenceCubit,
required FilterCubit filterCubit,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
Logger? logger, Logger? logger,
}) : _preferenceCubit = preferenceCubit, }) : _preferenceCubit = preferenceCubit,
_filterCubit = filterCubit,
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository = _storiesRepository =
@ -45,6 +47,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} }
final PreferenceCubit _preferenceCubit; final PreferenceCubit _preferenceCubit;
final FilterCubit _filterCubit;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
@ -74,7 +77,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final int pageSize = getPageSize(isComplexTile: isComplexTile); final int pageSize = getPageSize(isComplexTile: isComplexTile);
emit( emit(
const StoriesState.init().copyWith( const StoriesState.init().copyWith(
offlineReading: hasCachedStories && isOfflineReading: hasCachedStories &&
// Only go into offline mode in the next session. // Only go into offline mode in the next session.
state.downloadStatus == StoriesDownloadStatus.initial, state.downloadStatus == StoriesDownloadStatus.initial,
currentPageSize: pageSize, currentPageSize: pageSize,
@ -92,7 +95,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required StoryType type, required StoryType type,
required Emitter<StoriesState> emit, required Emitter<StoriesState> emit,
}) async { }) async {
if (state.offlineReading) { if (state.isOfflineReading) {
final List<int> ids = final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type); await _offlineRepository.getCachedStoryIds(type: type);
emit( emit(
@ -137,7 +140,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
), ),
); );
if (state.offlineReading) { if (state.isOfflineReading) {
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
type: event.type, type: event.type,
@ -172,7 +175,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
upper = len; upper = len;
} }
if (state.offlineReading) { if (state.isOfflineReading) {
_offlineRepository _offlineRepository
.getCachedStoriesStream( .getCachedStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist( ids: state.storyIdsByType[event.type]!.sublist(
@ -224,10 +227,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
) async { ) async {
final bool hasRead = await _preferenceRepository.hasRead(event.story.id); final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) =>
event.story.title.toLowerCase().contains(keyword) ||
event.story.text.toLowerCase().contains(keyword),
);
emit( emit(
state.copyWithStoryAdded( state.copyWithStoryAdded(
type: event.type, type: event.type,
story: event.story, story: event.story.copyWith(hidden: hidden),
hasRead: hasRead, hasRead: hasRead,
), ),
); );
@ -440,7 +448,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.deleteAllStories(); await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments(); await _offlineRepository.deleteAllComments();
await _offlineRepository.deleteAllWebPages(); await _offlineRepository.deleteAllWebPages();
emit(state.copyWith(offlineReading: false)); emit(state.copyWith(isOfflineReading: false));
add(StoriesInitialize()); add(StoriesInitialize());
} }

View File

@ -21,7 +21,7 @@ class StoriesState extends Equatable {
required this.statusByType, required this.statusByType,
required this.currentPageByType, required this.currentPageByType,
required this.readStoriesIds, required this.readStoriesIds,
required this.offlineReading, required this.isOfflineReading,
required this.downloadStatus, required this.downloadStatus,
required this.currentPageSize, required this.currentPageSize,
required this.storiesDownloaded, required this.storiesDownloaded,
@ -57,7 +57,7 @@ class StoriesState extends Equatable {
StoryType.ask: 0, StoryType.ask: 0,
StoryType.show: 0, StoryType.show: 0,
}, },
}) : offlineReading = false, }) : isOfflineReading = false,
downloadStatus = StoriesDownloadStatus.initial, downloadStatus = StoriesDownloadStatus.initial,
currentPageSize = 0, currentPageSize = 0,
readStoriesIds = const <int>{}, readStoriesIds = const <int>{},
@ -70,7 +70,7 @@ class StoriesState extends Equatable {
final Map<StoryType, int> currentPageByType; final Map<StoryType, int> currentPageByType;
final Set<int> readStoriesIds; final Set<int> readStoriesIds;
final StoriesDownloadStatus downloadStatus; final StoriesDownloadStatus downloadStatus;
final bool offlineReading; final bool isOfflineReading;
final int currentPageSize; final int currentPageSize;
final int storiesDownloaded; final int storiesDownloaded;
final int storiesToBeDownloaded; final int storiesToBeDownloaded;
@ -82,7 +82,7 @@ class StoriesState extends Equatable {
Map<StoryType, int>? currentPageByType, Map<StoryType, int>? currentPageByType,
Set<int>? readStoriesIds, Set<int>? readStoriesIds,
StoriesDownloadStatus? downloadStatus, StoriesDownloadStatus? downloadStatus,
bool? offlineReading, bool? isOfflineReading,
int? currentPageSize, int? currentPageSize,
int? storiesDownloaded, int? storiesDownloaded,
int? storiesToBeDownloaded, int? storiesToBeDownloaded,
@ -93,7 +93,7 @@ class StoriesState extends Equatable {
statusByType: statusByType ?? this.statusByType, statusByType: statusByType ?? this.statusByType,
currentPageByType: currentPageByType ?? this.currentPageByType, currentPageByType: currentPageByType ?? this.currentPageByType,
readStoriesIds: readStoriesIds ?? this.readStoriesIds, readStoriesIds: readStoriesIds ?? this.readStoriesIds,
offlineReading: offlineReading ?? this.offlineReading, isOfflineReading: isOfflineReading ?? this.isOfflineReading,
downloadStatus: downloadStatus ?? this.downloadStatus, downloadStatus: downloadStatus ?? this.downloadStatus,
currentPageSize: currentPageSize ?? this.currentPageSize, currentPageSize: currentPageSize ?? this.currentPageSize,
storiesDownloaded: storiesDownloaded ?? this.storiesDownloaded, storiesDownloaded: storiesDownloaded ?? this.storiesDownloaded,
@ -183,7 +183,7 @@ class StoriesState extends Equatable {
statusByType, statusByType,
currentPageByType, currentPageByType,
readStoriesIds, readStoriesIds,
offlineReading, isOfflineReading,
downloadStatus, downloadStatus,
currentPageSize, currentPageSize,
storiesDownloaded, storiesDownloaded,

View File

@ -2,10 +2,12 @@ import 'package:hacki/extensions/extensions.dart';
abstract class Constants { abstract class Constants {
static const String endUserAgreementLink = static const String endUserAgreementLink =
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45'; 'https://github.com/Livinglist/Hacki/blob/master/assets/eula.md';
static const String privacyPolicyLink =
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
static const String hackerNewsLogoLink = static const String hackerNewsLogoLink =
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png'; 'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
static const String portfolioLink = 'https://livinglist.github.io'; static const String portfolioLink = 'https://github.com/Livinglist';
static const String githubLink = 'https://github.com/Livinglist/Hacki'; static const String githubLink = 'https://github.com/Livinglist/Hacki';
static const String appStoreLink = static const String appStoreLink =
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review'; 'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
@ -16,6 +18,8 @@ abstract class Constants {
'https://news.ycombinator.com/newsguidelines.html'; 'https://news.ycombinator.com/newsguidelines.html';
static const String githubIssueLink = static const String githubIssueLink =
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.'; '$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
static const String supportEmail = 'georgefung98@gmail.com'; static const String supportEmail = 'georgefung98@gmail.com';
static const String _imagePath = 'assets/images'; static const String _imagePath = 'assets/images';
@ -35,6 +39,8 @@ abstract class Constants {
static const String featureOpenStoryInWebView = 'open_story_in_web_view'; static const String featureOpenStoryInWebView = 'open_story_in_web_view';
static const String featureLogIn = 'log_in'; static const String featureLogIn = 'log_in';
static const String featurePinToTop = 'pin_to_top'; static const String featurePinToTop = 'pin_to_top';
static const String featureJumpUpButton = 'jump_up_button';
static const String featureJumpDownButton = 'jump_down_button';
static final String happyFace = <String>[ static final String happyFace = <String>[
'(๑•̀ㅂ•́)و✧', '(๑•̀ㅂ•́)و✧',
@ -57,7 +63,15 @@ abstract class Constants {
'(ㆆ_ㆆ)', '(ㆆ_ㆆ)',
].pickRandomly()!; ].pickRandomly()!;
static final String magicWord = <String>[
'to be over the rainbow!',
'to infinity and beyond!',
'to see the future.',
].pickRandomly()!;
static final String errorMessage = 'Something went wrong...$sadFace'; static final String errorMessage = 'Something went wrong...$sadFace';
static final String loginErrorMessage =
'''Failed to log in $sadFace, this could happen if your account requires a CAPTCHA, please try logging in inside a browser to see if this is the case, if so, you may try logging in here again later after CAPTCHA is no longer needed.''';
} }
abstract class RegExpConstants { abstract class RegExpConstants {

View File

@ -32,7 +32,8 @@ Future<void> setUpLocator() async {
..registerSingleton<OfflineRepository>(OfflineRepository()) ..registerSingleton<OfflineRepository>(OfflineRepository())
..registerSingleton<DraftCache>(DraftCache()) ..registerSingleton<DraftCache>(DraftCache())
..registerSingleton<CommentCache>(CommentCache()) ..registerSingleton<CommentCache>(CommentCache())
..registerSingleton<LocalNotification>(LocalNotification()) ..registerSingleton<LocalNotificationService>(LocalNotificationService())
..registerSingleton(AppReviewService())
..registerSingleton<RouteObserver<ModalRoute<dynamic>>>( ..registerSingleton<RouteObserver<ModalRoute<dynamic>>>(
RouteObserver<ModalRoute<dynamic>>(), RouteObserver<ModalRoute<dynamic>>(),
); );

View File

@ -3,7 +3,6 @@ import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
part 'collapse_state.dart'; part 'collapse_state.dart';
@ -11,16 +10,13 @@ part 'collapse_state.dart';
class CollapseCubit extends Cubit<CollapseState> { class CollapseCubit extends Cubit<CollapseState> {
CollapseCubit({ CollapseCubit({
required int commentId, required int commentId,
required CommentsCubit? commentsCubit,
CollapseCache? collapseCache, CollapseCache? collapseCache,
}) : _commentId = commentId, }) : _commentId = commentId,
_collapseCache = collapseCache ?? locator.get<CollapseCache>(), _collapseCache = collapseCache ?? locator.get<CollapseCache>(),
_commentsCubit = commentsCubit,
super(const CollapseState.init()); super(const CollapseState.init());
final int _commentId; final int _commentId;
final CollapseCache _collapseCache; final CollapseCache _collapseCache;
final CommentsCubit? _commentsCubit;
late final StreamSubscription<Map<int, Set<int>>> _streamSubscription; late final StreamSubscription<Map<int, Set<int>>> _streamSubscription;
void init() { void init() {
@ -47,16 +43,7 @@ class CollapseCubit extends Cubit<CollapseState> {
), ),
); );
} else { } else {
if (_commentsCubit == null) return;
final Set<int> collapsedCommentIds = _collapseCache.collapse(_commentId); final Set<int> collapsedCommentIds = _collapseCache.collapse(_commentId);
final int lastCommentId = _commentsCubit!.state.comments.last.id;
final bool shouldLoadMore = _commentId == lastCommentId ||
collapsedCommentIds.contains(lastCommentId);
if (shouldLoadMore) {
_commentsCubit!.loadMore();
}
emit( emit(
state.copyWith( state.copyWith(

View File

@ -1,33 +1,40 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart'; import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:linkify/linkify.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
part 'comments_state.dart'; part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> { class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({ CommentsCubit({
required FilterCubit filterCubit,
required CollapseCache collapseCache, required CollapseCache collapseCache,
required bool isOfflineReading,
required Item item,
required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder,
CommentCache? commentCache, CommentCache? commentCache,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
Logger? logger, Logger? logger,
required bool offlineReading, }) : _filterCubit = filterCubit,
required Item item, _collapseCache = collapseCache,
required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder,
}) : _collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(), _commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
@ -38,13 +45,14 @@ class CommentsCubit extends Cubit<CommentsState> {
_logger = logger ?? locator.get<Logger>(), _logger = logger ?? locator.get<Logger>(),
super( super(
CommentsState.init( CommentsState.init(
offlineReading: offlineReading, isOfflineReading: isOfflineReading,
item: item, item: item,
fetchMode: defaultFetchMode, fetchMode: defaultFetchMode,
order: defaultCommentsOrder, order: defaultCommentsOrder,
), ),
); );
final FilterCubit _filterCubit;
final CollapseCache _collapseCache; final CollapseCache _collapseCache;
final CommentCache _commentCache; final CommentCache _commentCache;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
@ -61,8 +69,6 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions = final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{}; <int, StreamSubscription<Comment>>{};
static const int _pageSize = 20;
@override @override
void emit(CommentsState state) { void emit(CommentsState state) {
if (!isClosed) { if (!isClosed) {
@ -73,12 +79,12 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> init({ Future<void> init({
bool onlyShowTargetComment = false, bool onlyShowTargetComment = false,
bool useCommentCache = false, bool useCommentCache = false,
List<Comment>? targetParents, List<Comment>? targetAncestors,
}) async { }) async {
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) { if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
emit( emit(
state.copyWith( state.copyWith(
comments: targetParents, comments: targetAncestors,
onlyShowTargetComment: true, onlyShowTargetComment: true,
status: CommentsStatus.allLoaded, status: CommentsStatus.allLoaded,
), ),
@ -86,9 +92,11 @@ class CommentsCubit extends Cubit<CommentsState> {
_streamSubscription = _storiesRepository _streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream( .fetchAllCommentsRecursivelyStream(
ids: targetParents!.last.kids, ids: targetAncestors!.last.kids,
level: targetParents.last.level + 1, level: targetAncestors.last.level + 1,
) )
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched) .listen(_onCommentFetched)
..onDone(_onDone); ..onDone(_onDone);
@ -104,40 +112,38 @@ class CommentsCubit extends Cubit<CommentsState> {
); );
final Item item = state.item; final Item item = state.item;
final Item updatedItem = state.offlineReading final Item updatedItem = state.isOfflineReading
? item ? item
: await _storiesRepository.fetchItem(id: item.id) ?? item; : await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
final List<int> kids = sortKids(updatedItem.kids); item;
final List<int> kids = _sortKids(updatedItem.kids);
emit(state.copyWith(item: updatedItem)); emit(state.copyWith(item: updatedItem));
if (state.offlineReading) { late final Stream<Comment> commentStream;
_streamSubscription = _offlineRepository
.getCachedCommentsStream(ids: kids) if (state.isOfflineReading) {
.listen(_onCommentFetched) commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
..onDone(_onDone);
} else { } else {
switch (state.fetchMode) { switch (state.fetchMode) {
case FetchMode.lazy: case FetchMode.lazy:
_streamSubscription = _storiesRepository commentStream = _storiesRepository.fetchCommentsStream(
.fetchCommentsStream( ids: kids,
ids: kids, getFromCache: useCommentCache ? _commentCache.getComment : null,
getFromCache: useCommentCache ? _commentCache.getComment : null, );
)
.listen(_onCommentFetched)
..onDone(_onDone);
break;
case FetchMode.eager: case FetchMode.eager:
_streamSubscription = _storiesRepository commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
.fetchAllCommentsRecursivelyStream( ids: kids,
ids: kids, getFromCache: useCommentCache ? _commentCache.getComment : null,
getFromCache: useCommentCache ? _commentCache.getComment : null, );
)
.listen(_onCommentFetched)
..onDone(_onDone);
break;
} }
} }
_streamSubscription = commentStream
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
} }
Future<void> refresh() async { Future<void> refresh() async {
@ -147,7 +153,7 @@ class CommentsCubit extends Cubit<CommentsState> {
), ),
); );
if (state.offlineReading) { if (state.isOfflineReading) {
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.allLoaded, status: CommentsStatus.allLoaded,
@ -174,24 +180,25 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item; final Item item = state.item;
final Item updatedItem = final Item updatedItem =
await _storiesRepository.fetchItem(id: item.id) ?? item; await _storiesRepository.fetchItem(id: item.id) ?? item;
final List<int> kids = sortKids(updatedItem.kids); final List<int> kids = _sortKids(updatedItem.kids);
late final Stream<Comment> commentStream;
if (state.fetchMode == FetchMode.lazy) { if (state.fetchMode == FetchMode.lazy) {
_streamSubscription = _storiesRepository commentStream = _storiesRepository.fetchCommentsStream(
.fetchCommentsStream( ids: kids,
ids: kids, );
)
.listen(_onCommentFetched)
..onDone(_onDone);
} else { } else {
_streamSubscription = _storiesRepository commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
.fetchAllCommentsRecursivelyStream( ids: kids,
ids: kids, );
)
.listen(_onCommentFetched)
..onDone(_onDone);
} }
_streamSubscription = commentStream
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
emit( emit(
state.copyWith( state.copyWith(
item: updatedItem, item: updatedItem,
@ -200,7 +207,7 @@ class CommentsCubit extends Cubit<CommentsState> {
} }
void loadAll(Story story) { void loadAll(Story story) {
HapticFeedback.lightImpact(); HapticFeedbackUtil.light();
emit( emit(
state.copyWith( state.copyWith(
onlyShowTargetComment: false, onlyShowTargetComment: false,
@ -211,7 +218,11 @@ class CommentsCubit extends Cubit<CommentsState> {
} }
/// [comment] is only used for lazy fetching. /// [comment] is only used for lazy fetching.
void loadMore({Comment? comment}) { void loadMore({
Comment? comment,
void Function(Comment)? onCommentFetched,
VoidCallback? onDone,
}) {
if (comment == null && state.status == CommentsStatus.loading) return; if (comment == null && state.status == CommentsStatus.loading) return;
switch (state.fetchMode) { switch (state.fetchMode) {
@ -227,23 +238,18 @@ class CommentsCubit extends Cubit<CommentsState> {
final StreamSubscription<Comment> streamSubscription = final StreamSubscription<Comment> streamSubscription =
_storiesRepository _storiesRepository
.fetchCommentsStream(ids: comment.kids) .fetchCommentsStream(ids: comment.kids)
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen((Comment cmt) { .listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent); _collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt); _commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt); _sembastRepository.cacheComment(cmt);
final List<LinkifyElement> elements = _linkify(
cmt.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(cmt, elements: elements);
emit( emit(
state.copyWith( state.copyWith(
comments: <Comment>[...state.comments]..insert( comments: <Comment>[...state.comments]..insert(
state.comments.indexOf(comment) + offset + 1, state.comments.indexOf(comment) + offset + 1,
buildableComment.copyWith(level: level), cmt.copyWith(level: level),
), ),
), ),
); );
@ -260,21 +266,21 @@ class CommentsCubit extends Cubit<CommentsState> {
}); });
_streamSubscriptions[comment.id] = streamSubscription; _streamSubscriptions[comment.id] = streamSubscription;
break;
case FetchMode.eager: case FetchMode.eager:
if (_streamSubscription != null) { if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading)); emit(state.copyWith(status: CommentsStatus.loading));
_streamSubscription?.resume(); _streamSubscription
?..resume()
..onData(onCommentFetched);
} }
break;
} }
} }
Future<void> loadParentThread() async { Future<void> loadParentThread() async {
unawaited(HapticFeedback.lightImpact()); HapticFeedbackUtil.light();
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading)); emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
final Story? parent = final Item? parent =
await _storiesRepository.fetchParentStory(id: state.item.id); await _storiesRepository.fetchItem(id: state.item.parent);
if (parent == null) { if (parent == null) {
return; return;
@ -292,10 +298,33 @@ class CommentsCubit extends Cubit<CommentsState> {
} }
} }
Future<void> loadRootThread() async {
HapticFeedbackUtil.light();
emit(state.copyWith(fetchRootStatus: CommentsStatus.loading));
final Story? parent = await _storiesRepository
.fetchParentStory(id: state.item.id)
.then(_toBuildableStory);
if (parent == null) {
return;
} else {
await HackiApp.navigatorKey.currentState?.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: parent),
);
emit(
state.copyWith(
fetchRootStatus: CommentsStatus.loaded,
),
);
}
}
void onOrderChanged(CommentsOrder? order) { void onOrderChanged(CommentsOrder? order) {
if (order == null) return; if (order == null) return;
if (state.order == order) return; if (state.order == order) return;
HapticFeedback.selectionClick(); HapticFeedbackUtil.selection();
_streamSubscription?.cancel(); _streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) { for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
s.cancel(); s.cancel();
@ -309,7 +338,7 @@ class CommentsCubit extends Cubit<CommentsState> {
if (fetchMode == null) return; if (fetchMode == null) return;
if (state.fetchMode == fetchMode) return; if (state.fetchMode == fetchMode) return;
_collapseCache.resetCollapsedComments(); _collapseCache.resetCollapsedComments();
HapticFeedback.selectionClick(); HapticFeedbackUtil.selection();
_streamSubscription?.cancel(); _streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) { for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
s.cancel(); s.cancel();
@ -319,7 +348,82 @@ class CommentsCubit extends Cubit<CommentsState> {
init(useCommentCache: true); init(useCommentCache: true);
} }
List<int> sortKids(List<int> kids) { void jump(
ItemScrollController itemScrollController,
ItemPositionsListener itemPositionsListener,
) {
final int totalComments = state.comments.length;
final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value
// The header is also a part of the list view,
// thus ignoring it here.
.where((ItemPosition e) => e.index >= 1 && e.itemLeadingEdge < 0.7)
.sorted((ItemPosition a, ItemPosition b) => a.index.compareTo(b.index))
.map(
(ItemPosition e) => e.index <= state.comments.length
? state.comments.elementAt(e.index - 1)
: null,
)
.whereNotNull()
.toList();
/// The index of last comment visible on screen.
final int lastVisibleIndex = state.comments.indexOf(onScreenComments.last);
final int startIndex = min(lastVisibleIndex + 1, totalComments);
for (int i = startIndex; i < totalComments; i++) {
final Comment cmt = state.comments.elementAt(i);
if (cmt.isRoot && (cmt.deleted || cmt.dead) == false) {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: const Duration(milliseconds: 400),
);
return;
}
}
}
void jumpUp(
ItemScrollController itemScrollController,
ItemPositionsListener itemPositionsListener,
) {
final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value
// The header is also a part of the list view,
// thus ignoring it here.
.where((ItemPosition e) => e.index >= 1 && e.itemLeadingEdge > 0)
.sorted((ItemPosition a, ItemPosition b) => a.index.compareTo(b.index))
.map(
(ItemPosition e) => e.index <= state.comments.length
? state.comments.elementAt(e.index - 1)
: null,
)
.whereNotNull()
.toList();
/// The index of first comment visible on screen.
final int firstVisibleIndex = state.comments.indexOf(
onScreenComments.firstOrNull ?? state.comments.last,
);
final int startIndex = max(0, firstVisibleIndex - 1);
for (int i = startIndex; i >= 0; i--) {
final Comment cmt = state.comments.elementAt(i);
if (cmt.isRoot && (cmt.deleted || cmt.dead) == false) {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: const Duration(milliseconds: 400),
);
return;
}
}
}
List<int> _sortKids(List<int> kids) {
switch (state.order) { switch (state.order) {
case CommentsOrder.natural: case CommentsOrder.natural:
return kids; return kids;
@ -340,76 +444,69 @@ class CommentsCubit extends Cubit<CommentsState> {
); );
} }
void _onCommentFetched(Comment? comment) { void _onCommentFetched(BuildableComment? comment) {
if (comment != null) { if (comment != null) {
_collapseCache.addKid(comment.id, to: comment.parent); _collapseCache.addKid(comment.id, to: comment.parent);
_commentCache.cacheComment(comment); _commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment); _sembastRepository.cacheComment(comment);
final List<LinkifyElement> elements = _linkify( final bool hidden = _filterCubit.state.keywords.any(
comment.text, (String keyword) => comment.text.toLowerCase().contains(keyword),
); );
final BuildableComment buildableComment =
BuildableComment.fromComment(comment, elements: elements);
final List<Comment> updatedComments = <Comment>[ final List<Comment> updatedComments = <Comment>[
...state.comments, ...state.comments,
buildableComment comment.copyWith(hidden: hidden),
]; ];
emit(state.copyWith(comments: updatedComments)); emit(state.copyWith(comments: updatedComments));
if (state.fetchMode == FetchMode.eager) {
if (updatedComments.length >=
_pageSize + _pageSize * state.currentPage &&
updatedComments.length <=
_pageSize * 2 + _pageSize * state.currentPage) {
final bool isHidden = _collapseCache.isHidden(comment.id);
if (!isHidden) {
_streamSubscription?.pause();
emit(
state.copyWith(
status: CommentsStatus.loaded,
),
);
}
emit(
state.copyWith(
currentPage: state.currentPage + 1,
),
);
}
}
} }
} }
static List<LinkifyElement> _linkify( static Future<Item?> _toBuildable(Item? item) async {
String text, { if (item == null) return null;
LinkifyOptions options = const LinkifyOptions(),
List<Linkifier> linkifiers = const <Linkifier>[
UrlLinkifier(),
EmailLinkifier(),
],
}) {
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
if (text.isEmpty) { switch (item.runtimeType) {
return <LinkifyElement>[]; case Comment:
return _toBuildableComment(item as Comment);
case Story:
return _toBuildableStory(item as Story);
} }
if (linkifiers.isEmpty) { return null;
return list; }
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
if (comment == null) return null;
final List<LinkifyElement> elements =
await compute<String, List<LinkifyElement>>(
LinkifierUtil.linkify,
comment.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(comment, elements: elements);
return buildableComment;
}
static Future<BuildableStory?> _toBuildableStory(Story? story) async {
if (story == null) {
return null;
} else if (story.text.isEmpty) {
return BuildableStory.fromTitleOnlyStory(story);
} }
for (final Linkifier linkifier in linkifiers) { final List<LinkifyElement> elements =
list = linkifier.parse(list, options); await compute<String, List<LinkifyElement>>(
} LinkifierUtil.linkify,
story.text,
);
return list; final BuildableStory buildableStory =
BuildableStory.fromStory(story, elements: elements);
return buildableStory;
} }
@override @override

View File

@ -14,21 +14,23 @@ class CommentsState extends Equatable {
required this.comments, required this.comments,
required this.status, required this.status,
required this.fetchParentStatus, required this.fetchParentStatus,
required this.fetchRootStatus,
required this.order, required this.order,
required this.fetchMode, required this.fetchMode,
required this.onlyShowTargetComment, required this.onlyShowTargetComment,
required this.offlineReading, required this.isOfflineReading,
required this.currentPage, required this.currentPage,
}); });
CommentsState.init({ CommentsState.init({
required this.offlineReading, required this.isOfflineReading,
required this.item, required this.item,
required this.fetchMode, required this.fetchMode,
required this.order, required this.order,
}) : comments = <Comment>[], }) : comments = <Comment>[],
status = CommentsStatus.init, status = CommentsStatus.init,
fetchParentStatus = CommentsStatus.init, fetchParentStatus = CommentsStatus.init,
fetchRootStatus = CommentsStatus.init,
onlyShowTargetComment = false, onlyShowTargetComment = false,
currentPage = 0; currentPage = 0;
@ -36,10 +38,11 @@ class CommentsState extends Equatable {
final List<Comment> comments; final List<Comment> comments;
final CommentsStatus status; final CommentsStatus status;
final CommentsStatus fetchParentStatus; final CommentsStatus fetchParentStatus;
final CommentsStatus fetchRootStatus;
final CommentsOrder order; final CommentsOrder order;
final FetchMode fetchMode; final FetchMode fetchMode;
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final bool offlineReading; final bool isOfflineReading;
final int currentPage; final int currentPage;
CommentsState copyWith({ CommentsState copyWith({
@ -47,22 +50,24 @@ class CommentsState extends Equatable {
List<Comment>? comments, List<Comment>? comments,
CommentsStatus? status, CommentsStatus? status,
CommentsStatus? fetchParentStatus, CommentsStatus? fetchParentStatus,
CommentsStatus? fetchRootStatus,
CommentsOrder? order, CommentsOrder? order,
FetchMode? fetchMode, FetchMode? fetchMode,
bool? onlyShowTargetComment, bool? onlyShowTargetComment,
bool? offlineReading, bool? isOfflineReading,
int? currentPage, int? currentPage,
}) { }) {
return CommentsState( return CommentsState(
item: item ?? this.item, item: item ?? this.item,
comments: comments ?? this.comments, comments: comments ?? this.comments,
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus, fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
status: status ?? this.status, status: status ?? this.status,
order: order ?? this.order, order: order ?? this.order,
fetchMode: fetchMode ?? this.fetchMode, fetchMode: fetchMode ?? this.fetchMode,
onlyShowTargetComment: onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment, onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading, isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
); );
} }
@ -74,10 +79,11 @@ class CommentsState extends Equatable {
item, item,
status, status,
fetchParentStatus, fetchParentStatus,
fetchRootStatus,
order, order,
fetchMode, fetchMode,
onlyShowTargetComment, onlyShowTargetComment,
offlineReading, isOfflineReading,
currentPage, currentPage,
comments, comments,
]; ];

View File

@ -3,6 +3,7 @@ export 'collapse/collapse_cubit.dart';
export 'comments/comments_cubit.dart'; export 'comments/comments_cubit.dart';
export 'edit/edit_cubit.dart'; export 'edit/edit_cubit.dart';
export 'fav/fav_cubit.dart'; export 'fav/fav_cubit.dart';
export 'filter/filter_cubit.dart';
export 'history/history_cubit.dart'; export 'history/history_cubit.dart';
export 'notification/notification_cubit.dart'; export 'notification/notification_cubit.dart';
export 'pin/pin_cubit.dart'; export 'pin/pin_cubit.dart';

View File

@ -160,6 +160,13 @@ class FavCubit extends Cubit<FavState> {
}); });
} }
void removeAll() {
_preferenceRepository
..clearAllFavs(username: '')
..clearAllFavs(username: _authBloc.state.username);
emit(FavState.init());
}
void _onItemLoaded(Item item) { void _onItemLoaded(Item item) {
emit( emit(
state.copyWith( state.copyWith(

View File

@ -0,0 +1,40 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/repositories/repositories.dart';
part 'filter_state.dart';
class FilterCubit extends Cubit<FilterState> {
FilterCubit({PreferenceRepository? preferenceRepository})
: _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
super(FilterState.init()) {
init();
}
final PreferenceRepository _preferenceRepository;
void init() {
_preferenceRepository.filterKeywords.then(
(List<String> keywords) => emit(
state.copyWith(
keywords: keywords.toSet(),
),
),
);
}
void addKeyword(String keyword) {
final Set<String> updated = Set<String>.from(state.keywords)..add(keyword);
emit(state.copyWith(keywords: updated));
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
}
void removeKeyword(String keyword) {
final Set<String> updated = Set<String>.from(state.keywords)
..remove(keyword);
emit(state.copyWith(keywords: updated));
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
}
}

View File

@ -0,0 +1,20 @@
part of 'filter_cubit.dart';
class FilterState extends Equatable {
const FilterState({
required this.keywords,
});
FilterState.init() : keywords = <String>{};
final Set<String> keywords;
FilterState copyWith({Set<String>? keywords}) {
return FilterState(
keywords: keywords ?? this.keywords,
);
}
@override
List<Object?> get props => <Object?>[keywords];
}

View File

@ -67,10 +67,8 @@ class PreferenceCubit extends Cubit<PreferenceState> {
switch (T) { switch (T) {
case int: case int:
_preferenceRepository.setInt(preference.key, value as int); _preferenceRepository.setInt(preference.key, value as int);
break;
case bool: case bool:
_preferenceRepository.setBool(preference.key, value as bool); _preferenceRepository.setBool(preference.key, value as bool);
break;
default: default:
throw UnimplementedError(); throw UnimplementedError();
} }

View File

@ -52,8 +52,6 @@ class PreferenceState extends Equatable {
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>(); bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get webFirstEnabled => _isOn<NavigationModePreference>();
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>(); bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>(); bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();
@ -96,6 +94,9 @@ class PreferenceState extends Equatable {
FontSize get fontSize => FontSize.values FontSize get fontSize => FontSize.values
.elementAt(preferences.singleWhereType<FontSizePreference>().val); .elementAt(preferences.singleWhereType<FontSizePreference>().val);
Font get font =>
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
...preferences.map<dynamic>((Preference<dynamic> e) => e.val), ...preferences.map<dynamic>((Preference<dynamic> e) => e.val),

View File

@ -15,19 +15,19 @@ class SearchCubit extends Cubit<SearchState> {
final SearchRepository _searchRepository; final SearchRepository _searchRepository;
StreamSubscription<Story>? streamSubscription; StreamSubscription<Item>? streamSubscription;
void search(String query) { void search(String query) {
streamSubscription?.cancel(); streamSubscription?.cancel();
emit( emit(
state.copyWith( state.copyWith(
results: <Story>[], results: <Item>[],
status: SearchStatus.loading, status: SearchStatus.loading,
params: state.params.copyWith(query: query, page: 0), params: state.params.copyWith(query: query, page: 0),
), ),
); );
streamSubscription = streamSubscription =
_searchRepository.search(params: state.params).listen(_onStoryFetched) _searchRepository.search(params: state.params).listen(_onItemFetched)
..onDone(() { ..onDone(() {
emit(state.copyWith(status: SearchStatus.loaded)); emit(state.copyWith(status: SearchStatus.loaded));
}); });
@ -43,7 +43,7 @@ class SearchCubit extends Cubit<SearchState> {
), ),
); );
streamSubscription = streamSubscription =
_searchRepository.search(params: state.params).listen(_onStoryFetched) _searchRepository.search(params: state.params).listen(_onItemFetched)
..onDone(() { ..onDone(() {
emit(state.copyWith(status: SearchStatus.loaded)); emit(state.copyWith(status: SearchStatus.loaded));
}); });
@ -69,6 +69,8 @@ class SearchCubit extends Cubit<SearchState> {
} }
void removeFilter<T extends SearchFilter>() { void removeFilter<T extends SearchFilter>() {
if (state.params.contains<T>() == false) return;
emit( emit(
state.copyWith( state.copyWith(
params: state.params.copyWithFilterRemoved<T>(), params: state.params.copyWithFilterRemoved<T>(),
@ -78,6 +80,16 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query); search(state.params.query);
} }
void onToggled(TypeTagFilter filter) {
if (state.params.contains<TypeTagFilter>() &&
state.params.get<TypeTagFilter>() == filter) {
removeFilter<TypeTagFilter>();
} else {
removeFilter<TypeTagFilter>();
addFilter<TypeTagFilter>(filter);
}
}
void onSortToggled() { void onSortToggled() {
emit( emit(
state.copyWith( state.copyWith(
@ -90,10 +102,44 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query); search(state.params.query);
} }
void _onStoryFetched(Story story) { void onDateTimeRangeUpdated(DateTime start, DateTime end) {
final DateTime updatedStart = start.copyWith(
second: 0,
millisecond: 0,
microsecond: 0,
);
final DateTime updatedEnd = end.copyWith(
second: 0,
millisecond: 0,
microsecond: 0,
);
final DateTime? existingStart =
state.params.get<DateTimeRangeFilter>()?.startTime;
final DateTime? existingEnd =
state.params.get<DateTimeRangeFilter>()?.endTime;
if (existingStart == updatedStart && existingEnd == updatedEnd) return;
addFilter(
DateTimeRangeFilter(
startTime: updatedStart,
endTime: updatedEnd,
),
);
}
void onPostedByChanged(String? username) {
if (username == null) {
removeFilter<PostedByFilter>();
} else {
addFilter(PostedByFilter(author: username));
}
}
void _onItemFetched(Item item) {
emit( emit(
state.copyWith( state.copyWith(
results: List<Story>.from(state.results)..add(story), results: List<Item>.from(state.results)..add(item),
), ),
); );
} }

View File

@ -16,15 +16,21 @@ class SearchState extends Equatable {
SearchState.init() SearchState.init()
: status = SearchStatus.initial, : status = SearchStatus.initial,
results = <Story>[], results = <Item>[],
params = SearchParams.init(); params = SearchParams.init();
final List<Story> results; final List<Item> results;
final SearchStatus status; final SearchStatus status;
final SearchParams params; final SearchParams params;
bool get hasDateFilter =>
params.filters.whereType<DateTimeRangeFilter>().isNotEmpty;
DateTimeRangeFilter? get dateFilter =>
params.filters.whereType<DateTimeRangeFilter>().singleOrNull;
SearchState copyWith({ SearchState copyWith({
List<Story>? results, List<Item>? results,
SearchStatus? status, SearchStatus? status,
SearchParams? params, SearchParams? params,
}) { }) {

View File

@ -20,20 +20,20 @@ class TimeMachineCubit extends Cubit<TimeMachineState> {
final CommentCache _commentCache; final CommentCache _commentCache;
Future<void> activateTimeMachine(Comment comment) async { Future<void> activateTimeMachine(Comment comment) async {
emit(state.copyWith(parents: <Comment>[])); emit(state.copyWith(ancestors: <Comment>[]));
final List<Comment> parents = <Comment>[]; final List<Comment> ancestors = <Comment>[];
Comment? parent = _commentCache.getComment(comment.parent); Comment? parent = _commentCache.getComment(comment.parent);
parent ??= await _sembastRepository.getCachedComment(id: comment.parent); parent ??= await _sembastRepository.getCachedComment(id: comment.parent);
while (parent != null) { while (parent != null) {
parents.insert(0, parent); ancestors.insert(0, parent);
final int parentId = parent.parent; final int parentId = parent.parent;
parent = _commentCache.getComment(parentId); parent = _commentCache.getComment(parentId);
parent ??= await _sembastRepository.getCachedComment(id: parentId); parent ??= await _sembastRepository.getCachedComment(id: parentId);
} }
emit(state.copyWith(parents: parents)); emit(state.copyWith(ancestors: ancestors));
} }
} }

View File

@ -1,18 +1,18 @@
part of 'time_machine_cubit.dart'; part of 'time_machine_cubit.dart';
class TimeMachineState extends Equatable { class TimeMachineState extends Equatable {
const TimeMachineState({required this.parents}); const TimeMachineState({required this.ancestors});
TimeMachineState.init() : parents = <Comment>[]; TimeMachineState.init() : ancestors = <Comment>[];
final List<Comment> parents; final List<Comment> ancestors;
TimeMachineState copyWith({ TimeMachineState copyWith({
List<Comment>? parents, List<Comment>? ancestors,
}) { }) {
return TimeMachineState(parents: parents ?? this.parents); return TimeMachineState(ancestors: ancestors ?? this.ancestors);
} }
@override @override
List<Object?> get props => <Object?>[parents]; List<Object?> get props => <Object?>[ancestors];
} }

View File

@ -16,8 +16,13 @@ class UserCubit extends Cubit<UserState> {
void init({required String userId}) { void init({required String userId}) {
emit(state.copyWith(status: UserStatus.loading)); emit(state.copyWith(status: UserStatus.loading));
_storiesRepository.fetchUser(id: userId).then((User user) { _storiesRepository.fetchUser(id: userId).then((User? user) {
emit(state.copyWith(user: user, status: UserStatus.loaded)); emit(
state.copyWith(
user: user ?? User.emptyWithId(userId),
status: UserStatus.loaded,
),
);
}).onError((_, __) { }).onError((_, __) {
emit(state.copyWith(status: UserStatus.failure)); emit(state.copyWith(status: UserStatus.failure));
return; return;

View File

@ -30,7 +30,6 @@ extension ContextExtension on BuildContext {
textColor: Theme.of(this).textTheme.bodyLarge?.color, textColor: Theme.of(this).textTheme.bodyLarge?.color,
) )
: null, : null,
behavior: SnackBarBehavior.floating,
), ),
); );
} }

View File

@ -1,5 +1,5 @@
extension DateTimeExtension on DateTime { extension DateTimeExtension on DateTime {
String toReadableString() { String toTimeAgoString() {
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
final Duration diff = now.difference(this); final Duration diff = now.difference(this);
if (diff.inDays > 365) { if (diff.inDays > 365) {

View File

@ -2,7 +2,18 @@ import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
extension ObjectExtension on Object { extension ObjectExtension on Object {
void log({String identifier = ''}) { void log([String identifier = '']) {
locator.get<Logger>().d('$identifier ${toString()}'); locator.get<Logger>().d('$identifier ${toString()}');
} }
void logInfo({String identifier = ''}) {
locator.get<Logger>().i('$identifier ${toString()}');
}
void logError({
String identifier = '',
StackTrace? stackTrace,
}) {
locator.get<Logger>().e(identifier, this, stackTrace ?? StackTrace.current);
}
} }

View File

@ -1,13 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart'; import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart'; import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/models/models.dart'; import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart'; import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs; import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
@ -49,7 +46,7 @@ extension StateExtension on State {
} }
void onMoreTapped(Item item, Rect? rect) { void onMoreTapped(Item item, Rect? rect) {
HapticFeedback.lightImpact(); HapticFeedbackUtil.light();
if (item.dead || item.deleted) { if (item.dead || item.deleted) {
return; return;
@ -59,12 +56,14 @@ extension StateExtension on State {
context.read<BlocklistCubit>().state.blocklist.contains(item.by); context.read<BlocklistCubit>().state.blocklist.contains(item.by);
showModalBottomSheet<MenuAction>( showModalBottomSheet<MenuAction>(
context: context, context: context,
isScrollControlled: true,
builder: (BuildContext context) { builder: (BuildContext context) {
return MorePopupMenu( return SafeArea(
item: item, child: MorePopupMenu(
isBlocked: isBlocked, item: item,
onStoryLinkTapped: onStoryLinkTapped, isBlocked: isBlocked,
onLoginTapped: onLoginTapped, onLoginTapped: onLoginTapped,
),
); );
}, },
).then((MenuAction? action) { ).then((MenuAction? action) {
@ -74,15 +73,14 @@ extension StateExtension on State {
break; break;
case MenuAction.downvote: case MenuAction.downvote:
break; break;
case MenuAction.fav:
onFavTapped(item);
case MenuAction.share: case MenuAction.share:
onShareTapped(item, rect); onShareTapped(item, rect);
break;
case MenuAction.flag: case MenuAction.flag:
onFlagTapped(item); onFlagTapped(item);
break;
case MenuAction.block: case MenuAction.block:
onBlockTapped(item, isBlocked: isBlocked); onBlockTapped(item, isBlocked: isBlocked);
break;
case MenuAction.cancel: case MenuAction.cancel:
break; break;
} }
@ -90,24 +88,13 @@ extension StateExtension on State {
}); });
} }
Future<void> onStoryLinkTapped(String link) async { void onFavTapped(Item item) {
final int? id = link.itemId; final FavCubit favCubit = context.read<FavCubit>();
if (id != null) { final bool isFav = favCubit.state.favIds.contains(item.id);
await locator if (isFav) {
.get<StoriesRepository>() favCubit.removeFav(item.id);
.fetchItem(id: id)
.then((Item? item) {
if (mounted) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
}
});
} else { } else {
LinkUtil.launch(link); favCubit.addFav(item.id);
} }
} }
@ -117,24 +104,26 @@ extension StateExtension on State {
linkToShare = await showModalBottomSheet<String>( linkToShare = await showModalBottomSheet<String>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return Container( return SafeArea(
height: 140, child: ColoredBox(
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
child: Column( child: Column(
children: <Widget>[ mainAxisSize: MainAxisSize.min,
ListTile( children: <Widget>[
onTap: () => Navigator.pop(context, item.url), ListTile(
title: const Text('Link to article'), onTap: () => Navigator.pop(context, item.url),
), title: const Text('Link to article'),
ListTile(
onTap: () => Navigator.pop(
context,
'https://news.ycombinator.com/item?id=${item.id}',
), ),
title: const Text('Link to HN'), ListTile(
), onTap: () => Navigator.pop(
], context,
'https://news.ycombinator.com/item?id=${item.id}',
),
title: const Text('Link to HN'),
),
],
),
), ),
), ),
); );
@ -231,17 +220,11 @@ extension StateExtension on State {
} }
void onLoginTapped() { void onLoginTapped() {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext context) { builder: (BuildContext context) {
return LoginDialog( return const LoginDialog();
usernameController: usernameController,
passwordController: passwordController,
showSnackBar: showSnackBar,
);
}, },
); );
} }

View File

@ -1,4 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
import 'package:hacki/utils/utils.dart';
extension WidgetModifier on Widget { extension WidgetModifier on Widget {
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) { Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
@ -7,4 +11,62 @@ extension WidgetModifier on Widget {
child: this, child: this,
); );
} }
Widget contextMenuBuilder(
BuildContext context,
EditableTextState editableTextState, {
required Item item,
}) {
final int start = editableTextState.textEditingValue.selection.base.offset;
final int end = editableTextState.textEditingValue.selection.end;
final List<ContextMenuButtonItem> items = <ContextMenuButtonItem>[
...editableTextState.contextMenuButtonItems,
];
if (start != -1 && end != -1) {
String selectedText = item.text.substring(start, end);
if (item is Buildable) {
final Iterable<EmphasisElement> emphasisElements =
(item as Buildable).elements.whereType<EmphasisElement>();
int count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start + count * 2).clamp(0, item.text.length);
final int e = (end + count * 2).clamp(0, item.text.length);
selectedText = item.text.substring(s, e);
count++;
}
count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start - count * 2).clamp(0, item.text.length);
final int e = (end - count * 2).clamp(0, item.text.length);
selectedText = item.text.substring(s, e);
count++;
}
}
items.addAll(<ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () => LinkUtil.launch(
'''${Constants.wikipediaLink}$selectedText''',
),
label: 'Wikipedia',
),
ContextMenuButtonItem(
onPressed: () => LinkUtil.launch(
'''${Constants.wiktionaryLink}$selectedText''',
),
label: 'Wiktionary',
),
]);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: items,
);
}
} }

View File

@ -2,11 +2,11 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart'; import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
@ -17,9 +17,9 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/custom_bloc_observer.dart';
import 'package:hacki/services/fetcher.dart'; import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/theme_util.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
@ -110,13 +110,19 @@ Future<void> main({bool testing = false}) async {
}, },
); );
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
SystemChrome.setSystemUIOverlayStyle( final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
const SystemUiOverlayStyle( final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
statusBarColor: Palette.transparent, final int sdk = androidInfo.version.sdkInt;
systemNavigationBarColor: Palette.transparent,
systemNavigationBarDividerColor: Palette.transparent, if (sdk > 28) {
), SystemChrome.setSystemUIOverlayStyle(
); const SystemUiOverlayStyle(
statusBarColor: Palette.transparent,
systemNavigationBarColor: Palette.transparent,
systemNavigationBarDividerColor: Palette.transparent,
),
);
}
await SystemChrome.setEnabledSystemUIMode( await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge, SystemUiMode.edgeToEdge,
@ -128,8 +134,12 @@ Future<void> main({bool testing = false}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance(); final SharedPreferences prefs = await SharedPreferences.getInstance();
final bool trueDarkMode = final bool trueDarkMode =
prefs.getBool(const TrueDarkModePreference().key) ?? false; prefs.getBool(const TrueDarkModePreference().key) ?? false;
final Font font = Font.values.elementAt(
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
);
Bloc.observer = CustomBlocObserver(); //Uncomment this line to log events from bloc/cubit.
//Bloc.observer = CustomBlocObserver();
HydratedBloc.storage = storage; HydratedBloc.storage = storage;
@ -137,18 +147,21 @@ Future<void> main({bool testing = false}) async {
HackiApp( HackiApp(
savedThemeMode: savedThemeMode, savedThemeMode: savedThemeMode,
trueDarkMode: trueDarkMode, trueDarkMode: trueDarkMode,
font: font,
), ),
); );
} }
class HackiApp extends StatelessWidget { class HackiApp extends StatelessWidget {
const HackiApp({ const HackiApp({
required this.trueDarkMode,
required this.font,
super.key, super.key,
this.savedThemeMode, this.savedThemeMode,
required this.trueDarkMode,
}); });
final AdaptiveThemeMode? savedThemeMode; final AdaptiveThemeMode? savedThemeMode;
final Font font;
final bool trueDarkMode; final bool trueDarkMode;
static final GlobalKey<NavigatorState> navigatorKey = static final GlobalKey<NavigatorState> navigatorKey =
@ -162,9 +175,14 @@ class HackiApp extends StatelessWidget {
lazy: false, lazy: false,
create: (BuildContext context) => PreferenceCubit(), create: (BuildContext context) => PreferenceCubit(),
), ),
BlocProvider<FilterCubit>(
lazy: false,
create: (BuildContext context) => FilterCubit(),
),
BlocProvider<StoriesBloc>( BlocProvider<StoriesBloc>(
create: (BuildContext context) => StoriesBloc( create: (BuildContext context) => StoriesBloc(
preferenceCubit: context.read<PreferenceCubit>(), preferenceCubit: context.read<PreferenceCubit>(),
filterCubit: context.read<FilterCubit>(),
), ),
), ),
BlocProvider<AuthBloc>( BlocProvider<AuthBloc>(
@ -227,11 +245,13 @@ class HackiApp extends StatelessWidget {
child: AdaptiveTheme( child: AdaptiveTheme(
light: ThemeData( light: ThemeData(
primarySwatch: Palette.orange, primarySwatch: Palette.orange,
fontFamily: font.name,
), ),
dark: ThemeData( dark: ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
primarySwatch: Palette.orange, primarySwatch: Palette.orange,
canvasColor: trueDarkMode ? Palette.black : null, canvasColor: trueDarkMode ? Palette.black : null,
fontFamily: font.name,
), ),
initial: savedThemeMode ?? AdaptiveThemeMode.system, initial: savedThemeMode ?? AdaptiveThemeMode.system,
builder: (ThemeData theme, ThemeData darkTheme) { builder: (ThemeData theme, ThemeData darkTheme) {
@ -239,6 +259,7 @@ class HackiApp extends StatelessWidget {
brightness: Brightness.dark, brightness: Brightness.dark,
primarySwatch: Palette.orange, primarySwatch: Palette.orange,
canvasColor: Palette.black, canvasColor: Palette.black,
fontFamily: font.name,
); );
return FutureBuilder<AdaptiveThemeMode?>( return FutureBuilder<AdaptiveThemeMode?>(
future: AdaptiveTheme.getThemeMode(), future: AdaptiveTheme.getThemeMode(),
@ -247,6 +268,10 @@ class HackiApp extends StatelessWidget {
AsyncSnapshot<AdaptiveThemeMode?> snapshot, AsyncSnapshot<AdaptiveThemeMode?> snapshot,
) { ) {
final AdaptiveThemeMode? mode = snapshot.data; final AdaptiveThemeMode? mode = snapshot.data;
ThemeUtil.updateAndroidStatusBarSetting(
Theme.of(context).brightness,
mode,
);
return BlocBuilder<PreferenceCubit, PreferenceState>( return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: buildWhen:
(PreferenceState previous, PreferenceState current) => (PreferenceState previous, PreferenceState current) =>
@ -255,8 +280,9 @@ class HackiApp extends StatelessWidget {
final bool useTrueDark = prefState.trueDarkEnabled && final bool useTrueDark = prefState.trueDarkEnabled &&
(mode == AdaptiveThemeMode.dark || (mode == AdaptiveThemeMode.dark ||
(mode == AdaptiveThemeMode.system && (mode == AdaptiveThemeMode.system &&
SchedulerBinding View.of(context)
.instance.window.platformBrightness == .platformDispatcher
.platformBrightness ==
Brightness.dark)); Brightness.dark));
return FeatureDiscovery( return FeatureDiscovery(
child: MaterialApp( child: MaterialApp(

28
lib/models/font.dart Normal file
View File

@ -0,0 +1,28 @@
enum Font {
roboto('Roboto'),
robotoSlab('Roboto Slab', isSerif: true),
ubuntu('Ubuntu'),
ubuntuMono('Ubuntu Mono'),
notoSerif('Noto Serif', isSerif: true);
const Font(this.uiLabel, {this.isSerif = false});
final String uiLabel;
final bool isSerif;
static Font fromString(String? val) {
switch (val) {
case 'robotoSlab':
return Font.robotoSlab;
case 'ubuntu':
return Font.ubuntu;
case 'ubuntuMono':
return Font.ubuntuMono;
case 'notoSerif':
return Font.notoSerif;
case 'roboto':
default:
return Font.roboto;
}
}
}

View File

@ -0,0 +1,5 @@
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
mixin Buildable {
List<LinkifyElement> get elements;
}

View File

@ -1,10 +1,10 @@
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:hacki/models/item/buildable.dart';
import 'package:hacki/models/comment.dart'; import 'package:hacki/models/item/comment.dart';
import 'package:hacki/models/models.dart'; import 'package:linkify/linkify.dart';
/// [BuildableComment] is a subtype of [Comment] which stores /// [BuildableComment] is a subtype of [Comment] which stores
/// the corresponding [LinkifyElement] for faster widget building. /// the corresponding [LinkifyElement] for faster widget building.
class BuildableComment extends Comment { class BuildableComment extends Comment with Buildable {
BuildableComment({ BuildableComment({
required super.id, required super.id,
required super.time, required super.time,
@ -15,6 +15,7 @@ class BuildableComment extends Comment {
required super.kids, required super.kids,
required super.dead, required super.dead,
required super.deleted, required super.deleted,
required super.hidden,
required super.level, required super.level,
required this.elements, required this.elements,
}); });
@ -31,7 +32,30 @@ class BuildableComment extends Comment {
dead: comment.dead, dead: comment.dead,
deleted: comment.deleted, deleted: comment.deleted,
level: comment.level, level: comment.level,
hidden: comment.hidden,
); );
@override
BuildableComment copyWith({
int? level,
bool? hidden,
}) {
return BuildableComment(
id: id,
time: time,
parent: parent,
score: score,
by: by,
text: text,
kids: kids,
dead: dead,
deleted: deleted,
hidden: hidden ?? this.hidden,
level: level ?? this.level,
elements: elements,
);
}
@override
final List<LinkifyElement> elements; final List<LinkifyElement> elements;
} }

View File

@ -0,0 +1,48 @@
import 'package:hacki/models/item/buildable.dart';
import 'package:hacki/models/item/story.dart';
import 'package:linkify/linkify.dart';
/// [BuildableStory] is a subtype of [Story] which stores
/// the corresponding [LinkifyElement] for faster widget building.
class BuildableStory extends Story with Buildable {
const BuildableStory({
required super.id,
required super.time,
required super.score,
required super.by,
required super.text,
required super.kids,
required super.descendants,
required super.title,
required super.type,
required super.url,
required super.parts,
required super.hidden,
required this.elements,
});
BuildableStory.fromStory(Story story, {required this.elements})
: super(
id: story.id,
time: story.time,
score: story.score,
by: story.by,
text: story.text,
kids: story.kids,
descendants: story.descendants,
title: story.title,
type: story.type,
url: story.url,
parts: story.parts,
hidden: story.hidden,
);
BuildableStory.fromTitleOnlyStory(Story story)
: this.fromStory(
story,
elements: const <LinkifyElement>[],
);
@override
final List<LinkifyElement> elements;
}

View File

@ -1,4 +1,4 @@
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item/item.dart';
class Comment extends Item { class Comment extends Item {
Comment({ Comment({
@ -11,6 +11,7 @@ class Comment extends Item {
required super.kids, required super.kids,
required super.dead, required super.dead,
required super.deleted, required super.deleted,
required super.hidden,
required this.level, required this.level,
}) : super( }) : super(
descendants: 0, descendants: 0,
@ -24,9 +25,14 @@ class Comment extends Item {
final int level; final int level;
String get metadata => '''by $by $postedDate'''; String get metadata => '''by $by $timeAgo''';
Comment copyWith({int? level}) { bool get isRoot => level == 0;
Comment copyWith({
int? level,
bool? hidden,
}) {
return Comment( return Comment(
id: id, id: id,
time: time, time: time,
@ -37,42 +43,11 @@ class Comment extends Item {
kids: kids, kids: kids,
dead: dead, dead: dead,
deleted: deleted, deleted: deleted,
hidden: hidden ?? this.hidden,
level: level ?? this.level, level: level ?? this.level,
); );
} }
@override
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'time': time,
'by': by,
'text': text,
'kids': kids,
'parent': parent,
'deleted': deleted,
'dead': dead,
'score': score,
'level': level,
};
@override @override
bool? get stringify => false; bool? get stringify => false;
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
} }

View File

@ -1,8 +1,15 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.dart'; import 'package:hacki/extensions/date_time_extension.dart';
import 'package:hacki/models/comment.dart'; import 'package:hacki/models/item/comment.dart';
import 'package:hacki/models/poll_option.dart'; import 'package:hacki/models/item/poll_option.dart';
import 'package:hacki/models/story.dart'; import 'package:hacki/models/item/story.dart';
export 'buildable.dart';
export 'buildable_comment.dart';
export 'buildable_story.dart';
export 'comment.dart';
export 'poll_option.dart';
export 'story.dart';
/// [Item] is the base type of [Story], [Comment] and [PollOption]. /// [Item] is the base type of [Story], [Comment] and [PollOption].
class Item extends Equatable { class Item extends Equatable {
@ -21,6 +28,7 @@ class Item extends Equatable {
required this.type, required this.type,
required this.parts, required this.parts,
required this.descendants, required this.descendants,
required this.hidden,
}); });
Item.empty() Item.empty()
@ -32,9 +40,10 @@ class Item extends Equatable {
title = '', title = '',
url = '', url = '',
kids = <int>[], kids = <int>[],
dead = false,
parts = <int>[], parts = <int>[],
dead = false,
deleted = false, deleted = false,
hidden = false,
parent = 0, parent = 0,
text = '', text = '',
type = ''; type = '';
@ -53,7 +62,8 @@ class Item extends Equatable {
deleted = json['deleted'] as bool? ?? false, deleted = json['deleted'] as bool? ?? false,
parent = json['parent'] as int? ?? 0, parent = json['parent'] as int? ?? 0,
parts = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[], parts = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
type = json['type'] as String? ?? ''; type = json['type'] as String? ?? '',
hidden = json['hidden'] as bool? ?? false;
final int id; final int id;
final int time; final int time;
@ -66,6 +76,11 @@ class Item extends Equatable {
final bool deleted; final bool deleted;
final bool dead; final bool dead;
/// Whether or not the item should be hidden.
/// true if any of filter keywords set by user presents in [text]
/// or [title].
final bool hidden;
final String by; final String by;
final String text; final String text;
final String url; final String url;
@ -75,8 +90,8 @@ class Item extends Equatable {
final List<int> kids; final List<int> kids;
final List<int> parts; final List<int> parts;
String get postedDate => String get timeAgo =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString(); DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString();
bool get isPoll => type == 'poll'; bool get isPoll => type == 'poll';
@ -101,6 +116,7 @@ class Item extends Equatable {
'deleted': deleted, 'deleted': deleted,
'type': type, 'type': type,
'parts': parts, 'parts': parts,
'parent': parent,
}; };
} }
@ -120,5 +136,6 @@ class Item extends Equatable {
type, type,
parts, parts,
descendants, descendants,
hidden,
]; ];
} }

View File

@ -1,6 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item/item.dart';
class PollOption extends Item { class PollOption extends Item {
const PollOption({ const PollOption({
@ -20,6 +20,7 @@ class PollOption extends Item {
descendants: 0, descendants: 0,
dead: false, dead: false,
deleted: false, deleted: false,
hidden: false,
); );
PollOption.empty() PollOption.empty()
@ -52,19 +53,7 @@ class PollOption extends Item {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
'descendants': descendants, ...super.toJson(),
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
'parts': parts,
'ratio': ratio, 'ratio': ratio,
}; };
} }
@ -75,22 +64,4 @@ class PollOption extends Item {
const JsonEncoder.withIndent(' ').convert(this); const JsonEncoder.withIndent(' ').convert(this);
return 'PollOption $prettyString'; return 'PollOption $prettyString';
} }
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
} }

View File

@ -1,5 +1,5 @@
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item/item.dart';
class Story extends Item { class Story extends Item {
const Story({ const Story({
@ -14,6 +14,7 @@ class Story extends Item {
required super.text, required super.text,
required super.kids, required super.kids,
required super.parts, required super.parts,
required super.hidden,
}) : super( }) : super(
dead: false, dead: false,
deleted: false, deleted: false,
@ -38,15 +39,36 @@ class Story extends Item {
parent: 0, parent: 0,
text: '', text: '',
type: '', type: '',
hidden: false,
); );
Story.fromJson(super.json) : super.fromJson(); Story.fromJson(super.json) : super.fromJson();
Story copyWith({bool? hidden}) {
return Story(
descendants: descendants,
id: id,
score: score,
time: time,
by: by,
title: title,
type: type,
url: url,
text: text,
kids: kids,
parts: parts,
hidden: hidden ?? this.hidden,
);
}
String get metadata => String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $postedDate | $descendants comment${descendants > 1 ? 's' : ''}'''; '''$score point${score > 1 ? 's' : ''} by $by $timeAgo | $descendants comment${descendants > 1 ? 's' : ''}''';
String get screenReaderLabel =>
'''$title, at $readableUrl, by $by $timeAgo. This story has $score point${score > 1 ? 's' : ''} and $descendants comment${descendants > 1 ? 's' : ''}''';
String get simpleMetadata => String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate'''; '''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $timeAgo''';
String get readableUrl { String get readableUrl {
final Uri url = Uri.parse(this.url); final Uri url = Uri.parse(this.url);
@ -55,48 +77,5 @@ class Story extends Item {
} }
@override @override
Map<String, dynamic> toJson() { String toString() => 'Story $id';
return <String, dynamic>{
'descendants': descendants,
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
'parts': parts,
};
}
@override
String toString() {
// final String prettyString =
// const JsonEncoder.withIndent(' ').convert(this);
// return 'Story $prettyString';
return 'Story $id';
}
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
text,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
} }

View File

@ -1,13 +1,10 @@
export 'buildable_comment.dart';
export 'comment.dart';
export 'comments_order.dart'; export 'comments_order.dart';
export 'fetch_mode.dart'; export 'fetch_mode.dart';
export 'font.dart';
export 'font_size.dart'; export 'font_size.dart';
export 'item.dart'; export 'item/item.dart';
export 'poll_option.dart';
export 'post_data.dart'; export 'post_data.dart';
export 'preference.dart'; export 'preference.dart';
export 'search_params.dart'; export 'search_params.dart';
export 'story.dart';
export 'story_type.dart'; export 'story_type.dart';
export 'user.dart'; export 'user.dart';

View File

@ -17,9 +17,10 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
static final List<Preference<dynamic>> allPreferences = static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>( UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[ <Preference<dynamic>>[
// Order of these first four preferences does not matter. // Order of these preferences does not matter.
FetchModePreference(), FetchModePreference(),
CommentsOrderPreference(), CommentsOrderPreference(),
FontPreference(),
FontSizePreference(), FontSizePreference(),
TabOrderPreference(), TabOrderPreference(),
// Order of items below matters and // Order of items below matters and
@ -30,7 +31,6 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const NotificationModePreference(), const NotificationModePreference(),
const SwipeGesturePreference(), const SwipeGesturePreference(),
const CollapseModePreference(), const CollapseModePreference(),
NavigationModePreference(),
const ReaderModePreference(), const ReaderModePreference(),
const MarkReadStoriesModePreference(), const MarkReadStoriesModePreference(),
const EyeCandyModePreference(), const EyeCandyModePreference(),
@ -53,8 +53,6 @@ abstract class IntPreference extends Preference<int> {
const bool _notificationModeDefaultValue = true; const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false; const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true; const bool _displayModeDefaultValue = true;
const bool _navigationModeDefaultValueIOS = false;
const bool _navigationModeDefaultValueAndroid = false;
const bool _eyeCandyModeDefaultValue = false; const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false; const bool _trueDarkModeDefaultValue = false;
const bool _readerModeDefaultValue = true; const bool _readerModeDefaultValue = true;
@ -65,6 +63,7 @@ const bool _collapseModeDefaultValue = true;
final int _fetchModeDefaultValue = FetchMode.eager.index; final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index; final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final int _fontSizeDefaultValue = FontSize.regular.index; final int _fontSizeDefaultValue = FontSize.regular.index;
final int _fontDefaultValue = Font.roboto.index;
final int _tabOrderDefaultValue = final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values); StoryType.convertToSettingsValue(StoryType.values);
@ -188,32 +187,6 @@ class StoryUrlModePreference extends BooleanPreference {
String get subtitle => '''show url in story tile.'''; String get subtitle => '''show url in story tile.''';
} }
/// The value deciding whether or not user should be
/// navigated to web view first. Defaults to false.
class NavigationModePreference extends BooleanPreference {
NavigationModePreference({bool? val})
: super(
val: val ??
(Platform.isAndroid
? _navigationModeDefaultValueAndroid
: _navigationModeDefaultValueIOS),
);
@override
NavigationModePreference copyWith({required bool? val}) {
return NavigationModePreference(val: val);
}
@override
String get key => 'navigationMode';
@override
String get title => 'Show Web Page First';
@override
String get subtitle => '''show web page first after tapping on story.''';
}
class ReaderModePreference extends BooleanPreference { class ReaderModePreference extends BooleanPreference {
const ReaderModePreference({bool? val}) const ReaderModePreference({bool? val})
: super(val: val ?? _readerModeDefaultValue); : super(val: val ?? _readerModeDefaultValue);
@ -325,6 +298,21 @@ class CommentsOrderPreference extends IntPreference {
String get title => 'Default comments order'; String get title => 'Default comments order';
} }
class FontPreference extends IntPreference {
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
@override
FontPreference copyWith({required int? val}) {
return FontPreference(val: val);
}
@override
String get key => 'font';
@override
String get title => 'Default font';
}
class FontSizePreference extends IntPreference { class FontSizePreference extends IntPreference {
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue); FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);

View File

@ -8,8 +8,19 @@ abstract class NumericFilter extends SearchFilter {}
abstract class TagFilter extends SearchFilter {} abstract class TagFilter extends SearchFilter {}
abstract class TypeTagFilter extends TagFilter {
static List<TypeTagFilter> all = <TypeTagFilter>[
const StoryFilter(),
const PollFilter(),
const CommentFilter(),
const FrontPageFilter(),
const AskHnFilter(),
const ShowHnFilter(),
];
}
class DateTimeRangeFilter implements NumericFilter { class DateTimeRangeFilter implements NumericFilter {
DateTimeRangeFilter({ const DateTimeRangeFilter({
this.startTime, this.startTime,
this.endTime, this.endTime,
}); });
@ -19,14 +30,27 @@ class DateTimeRangeFilter implements NumericFilter {
@override @override
String get query { String get query {
if (startTime == null || endTime == null) return '';
final int? startTimestamp = startTime == null final int? startTimestamp = startTime == null
? null ? null
: startTime!.toUtc().millisecondsSinceEpoch ~/ 1000; : startTime!.toUtc().millisecondsSinceEpoch ~/ 1000;
final int? endTimestamp = endTime == null int? endTimestamp = endTime == null
? null ? null
: endTime!.toUtc().millisecondsSinceEpoch ~/ 1000; : endTime!.toUtc().millisecondsSinceEpoch ~/ 1000;
if (startTimestamp == endTimestamp) {
endTimestamp = startTime!
.add(const Duration(hours: 24))
.toUtc()
.millisecondsSinceEpoch ~/
1000;
}
if (startTimestamp == null || endTimestamp == null) return '';
final String query = final String query =
'''${startTimestamp == null ? '' : 'created_at_i>$startTimestamp'},${endTimestamp == null ? '' : 'created_at_i<$endTimestamp'}'''; '''created_at_i>=$startTimestamp, created_at_i<=$endTimestamp''';
if (query.endsWith(',')) { if (query.endsWith(',')) {
return query.replaceFirst(',', ''); return query.replaceFirst(',', '');
@ -37,7 +61,7 @@ class DateTimeRangeFilter implements NumericFilter {
} }
class PostedByFilter implements TagFilter { class PostedByFilter implements TagFilter {
PostedByFilter({required this.author}); const PostedByFilter({required this.author});
final String author; final String author;
@ -47,8 +71,8 @@ class PostedByFilter implements TagFilter {
} }
} }
class FrontPageFilter implements TagFilter { class FrontPageFilter implements TypeTagFilter {
FrontPageFilter(); const FrontPageFilter();
@override @override
String get query { String get query {
@ -56,8 +80,8 @@ class FrontPageFilter implements TagFilter {
} }
} }
class ShowHnFilter implements TagFilter { class ShowHnFilter implements TypeTagFilter {
ShowHnFilter(); const ShowHnFilter();
@override @override
String get query { String get query {
@ -65,8 +89,8 @@ class ShowHnFilter implements TagFilter {
} }
} }
class AskHnFilter implements TagFilter { class AskHnFilter implements TypeTagFilter {
AskHnFilter(); const AskHnFilter();
@override @override
String get query { String get query {
@ -74,8 +98,8 @@ class AskHnFilter implements TagFilter {
} }
} }
class PollFilter implements TagFilter { class PollFilter implements TypeTagFilter {
PollFilter(); const PollFilter();
@override @override
String get query { String get query {
@ -83,8 +107,8 @@ class PollFilter implements TagFilter {
} }
} }
class StoryFilter implements TagFilter { class StoryFilter implements TypeTagFilter {
StoryFilter(); const StoryFilter();
@override @override
String get query { String get query {
@ -92,8 +116,17 @@ class StoryFilter implements TagFilter {
} }
} }
class CommentFilter implements TypeTagFilter {
const CommentFilter();
@override
String get query {
return 'comment';
}
}
class CombinedFilter implements TagFilter { class CombinedFilter implements TagFilter {
CombinedFilter({required this.filters}); const CombinedFilter({required this.filters});
final List<TagFilter> filters; final List<TagFilter> filters;

View File

@ -70,7 +70,6 @@ class SearchParams extends Equatable {
filters.whereType<NumericFilter>(); filters.whereType<NumericFilter>();
final List<TagFilter> tagFilters = <TagFilter>[ final List<TagFilter> tagFilters = <TagFilter>[
...filters.whereType<TagFilter>(), ...filters.whereType<TagFilter>(),
CombinedFilter(filters: <TagFilter>[StoryFilter(), PollFilter()]),
]; ];
if (numericFilters.isNotEmpty) { if (numericFilters.isNotEmpty) {

View File

@ -17,6 +17,12 @@ class User extends Equatable {
id = '', id = '',
karma = 0; karma = 0;
const User.emptyWithId(this.id)
: about = '',
created = 0,
delay = 0,
karma = 0;
User.fromJson(Map<String, dynamic> json) User.fromJson(Map<String, dynamic> json)
: about = json['about'] as String? ?? '', : about = json['about'] as String? ?? '',
created = json['created'] as int? ?? 0, created = json['created'] as int? ?? 0,

View File

@ -22,6 +22,7 @@ class PreferenceRepository {
static const String _usernameKey = 'username'; static const String _usernameKey = 'username';
static const String _passwordKey = 'password'; static const String _passwordKey = 'password';
static const String _blocklistKey = 'blocklist'; static const String _blocklistKey = 'blocklist';
static const String _filterKeywordsKey = 'filterKeywords';
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds'; static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
static const String _unreadCommentsIdsKey = 'unreadCommentsIds'; static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
static const String _lastReadStoryIdKey = 'lastReadStoryId'; static const String _lastReadStoryIdKey = 'lastReadStoryId';
@ -207,6 +208,23 @@ class PreferenceRepository {
} }
} }
Future<void> clearAllFavs({required String username}) async {
final String key = _getFavKey(username);
if (Platform.isIOS) {
await _syncedPrefs.setStringList(
key: key,
val: <String>[],
);
} else {
final SharedPreferences prefs = await _prefs;
await prefs.setStringList(
key,
<String>[],
);
}
}
static String _getFavKey(String username) => 'fav_$username'; static String _getFavKey(String username) => 'fav_$username';
//#endregion //#endregion
@ -257,6 +275,20 @@ class PreferenceRepository {
//#endregion //#endregion
//#region filter
Future<List<String>> get filterKeywords async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getStringList(_filterKeywordsKey) ?? <String>[],
);
Future<void> updateFilterKeywords(List<String> keywords) async {
final SharedPreferences prefs = await _prefs;
await prefs.setStringList(_filterKeywordsKey, keywords);
}
//#endregion
//#region pins //#region pins
Future<List<int>> get pinnedStoriesIds async { Future<List<int>> get pinnedStoriesIds async {

View File

@ -13,7 +13,7 @@ class SearchRepository {
final Dio _dio; final Dio _dio;
Stream<Story> search({ Stream<Item> search({
required SearchParams params, required SearchParams params,
}) async* { }) async* {
final String url = '$_baseUrl${params.filteredQuery}'; final String url = '$_baseUrl${params.filteredQuery}';
@ -36,37 +36,55 @@ class SearchRepository {
final int score = hit['points'] as int? ?? 0; final int score = hit['points'] as int? ?? 0;
final int descendants = hit['num_comments'] as int? ?? 0; final int descendants = hit['num_comments'] as int? ?? 0;
// Getting rid of comments, only keeping stories for convenience.
// Don't judge me.
if (title.isEmpty) {
continue;
}
final String url = hit['url'] as String? ?? ''; final String url = hit['url'] as String? ?? '';
final String type = final String type =
title.toLowerCase().contains('poll:') ? 'poll' : 'story'; title.toLowerCase().contains('poll:') ? 'poll' : 'story';
final String text = hit['story_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final int id = int.parse(hit['objectID'] as String? ?? '0'); final int id = int.parse(hit['objectID'] as String? ?? '0');
final Story story = Story( if (title.isEmpty) {
descendants: descendants, final String text = hit['comment_text'] as String? ?? '';
id: id, final String parsedText = await compute<String, String>(
score: score, HtmlUtil.parseHtml,
time: createdAt, text,
by: by, );
title: title, final int parentId = hit['parent_id'] as int? ?? 0;
text: parsedText, final Comment comment = Comment(
url: url, id: id,
type: type, score: score,
// response doesn't contain kids and parts. time: createdAt,
kids: const <int>[], by: by,
parts: const <int>[], text: parsedText,
); kids: const <int>[],
yield story; parent: parentId,
dead: false,
deleted: false,
hidden: false,
level: 0,
);
yield comment;
} else {
final String text = hit['story_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final Story story = Story(
descendants: descendants,
id: id,
score: score,
time: createdAt,
by: by,
title: title,
text: parsedText,
url: url,
type: type,
// response doesn't contain kids and parts.
kids: const <int>[],
parts: const <int>[],
hidden: false,
);
yield story;
}
} }
return; return;
} }

View File

@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:tuple/tuple.dart';
/// [StoriesRepository] is for fetching /// [StoriesRepository] is for fetching
/// [Item] such as [Story], [PollOption], [Comment] or [User]. /// [Item] such as [Story], [PollOption], [Comment] or [User].
@ -17,216 +16,29 @@ class StoriesRepository {
final FirebaseClient _firebaseClient; final FirebaseClient _firebaseClient;
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/'; static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
/// Fetch a [User] by its [id]. Future<Map<String, dynamic>?> _fetchItemJson(int id) async {
/// Hacker News uses user's username as [id]. return _firebaseClient
Future<User> fetchUser({required String id}) async {
final User user = await _firebaseClient
.get('${_baseUrl}user/$id.json')
.then((dynamic val) {
final Map<String, dynamic> json = val as Map<String, dynamic>;
final User user = User.fromJson(json);
return user;
});
return user;
}
/// Fetch ids of stories of a certain [StoryType].
Future<List<int>> fetchStoryIds({required StoryType type}) async {
final List<int> ids = await _firebaseClient
.get('$_baseUrl${type.path}.json')
.then((dynamic val) {
final List<int> ids = (val as List<dynamic>).cast<int>();
return ids;
});
return ids;
}
/// Fetch a [Story] based on its id.
Future<Story?> fetchStory({required int id}) async {
final Story? story = await _firebaseClient
.get('${_baseUrl}item/$id.json') .get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?)) .then((dynamic json) => _parseJson(json as Map<String, dynamic>?));
.then((Map<String, dynamic>? json) {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
return story;
} }
/// Fetch a list of [Comment] based on ids and return results Future<Map<String, dynamic>?> _fetchRawItemJson(int id) async {
/// using a stream. return _firebaseClient
Stream<Comment> fetchCommentsStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
return comment;
});
if (comment != null) {
yield comment;
}
}
return;
}
/// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream.
Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
return comment;
});
if (comment != null) {
yield comment;
yield* fetchAllCommentsRecursivelyStream(
ids: comment.kids,
level: level + 1,
getFromCache: getFromCache,
);
}
}
return;
}
/// Fetch a list of [Item] based on ids and return results
/// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* {
for (final int id in ids) {
final Item? item = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job') {
final Story story = Story.fromJson(json);
return story;
} else if (json['type'] == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
if (item != null) {
yield item;
}
}
}
/// Fetch a list of [Story] based on ids and return results
/// using a stream.
Stream<Story> fetchStoriesStream({required List<int> ids}) async* {
for (final int id in ids) {
final Story? story = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
if (story != null) {
yield story;
}
}
}
/// Fetch a list of [PollOption] based on ids and return results
/// using a stream.
Stream<PollOption> fetchPollOptionsStream({required List<int> ids}) async* {
for (final int id in ids) {
final PollOption? option = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) async {
if (json == null) return null;
final PollOption option =
PollOption.fromJson(json as Map<String, dynamic>);
return option;
});
if (option != null) {
yield option;
}
}
}
/// Fetch a [Comment] based on its id.
Future<Comment?> fetchComment({required int id}) async {
final Comment? comment = await _firebaseClient
.get('${_baseUrl}item/$id.json') .get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?)) .then((dynamic value) => value as Map<String, dynamic>?);
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
/// Fetch a raw [Comment] based on its id.
/// The content of [Comment] will not be parsed, use this function only if
/// the format of content doesn't matter, otherwise, use [fetchComment].
Future<Comment?> fetchRawComment({required int id}) async {
final Comment? comment = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic val) async {
if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
} }
/// Fetch a [Item] based on its id. /// Fetch a [Item] based on its id.
Future<Item?> fetchItem({required int id}) async { Future<Item?> fetchItem({required int id}) async {
final Item? item = await _firebaseClient final Item? item =
.get('${_baseUrl}item/$id.json') await _fetchItemJson(id).then((Map<String, dynamic>? json) {
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) {
if (json == null) return null; if (json == null) return null;
final String type = json['type'] as String; final String type = json['type'] as String;
if (type == 'story' || type == 'job' || type == 'poll') { if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json); final Story story = Story.fromJson(json);
return story; return story;
} else if (json['type'] == 'comment') { } else if (type == 'comment') {
final Comment comment = Comment.fromJson(json); final Comment comment = Comment.fromJson(json);
return comment; return comment;
} }
@ -240,9 +52,7 @@ class StoriesRepository {
/// The content of [Item] will not be parsed, use this function only if /// The content of [Item] will not be parsed, use this function only if
/// the format of content doesn't matter, otherwise, use [fetchItem]. /// the format of content doesn't matter, otherwise, use [fetchItem].
Future<Item?> fetchRawItem({required int id}) async { Future<Item?> fetchRawItem({required int id}) async {
final Item? item = await _firebaseClient final Item? item = await _fetchRawItemJson(id).then((dynamic val) {
.get('${_baseUrl}item/$id.json')
.then((dynamic val) {
if (val == null) return null; if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>; final Map<String, dynamic> json = val as Map<String, dynamic>;
@ -251,7 +61,7 @@ class StoriesRepository {
if (type == 'story' || type == 'job' || type == 'poll') { if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json); final Story story = Story.fromJson(json);
return story; return story;
} else if (json['type'] == 'comment') { } else if (type == 'comment') {
final Comment comment = Comment.fromJson(json); final Comment comment = Comment.fromJson(json);
return comment; return comment;
} }
@ -261,6 +71,23 @@ class StoriesRepository {
return item; return item;
} }
/// Fetch a [User] by its [id].
/// Hacker News uses user's username as [id].
Future<User?> fetchUser({required String id}) async {
final User? user = await _firebaseClient
.get('${_baseUrl}user/$id.json')
.then((dynamic val) {
final Map<String, dynamic>? json = val as Map<String, dynamic>?;
if (json == null) return null;
final User user = User.fromJson(json);
return user;
});
return user;
}
/// Fetch a list of ids of [Story] or [Comment] submitted by the user. /// Fetch a list of ids of [Story] or [Comment] submitted by the user.
Future<List<int>?> fetchSubmitted({required String userId}) async { Future<List<int>?> fetchSubmitted({required String userId}) async {
final List<int>? submitted = await _firebaseClient final List<int>? submitted = await _firebaseClient
@ -278,6 +105,59 @@ class StoriesRepository {
return submitted; return submitted;
} }
/// Fetch ids of stories of a certain [StoryType].
Future<List<int>> fetchStoryIds({required StoryType type}) async {
final List<int> ids = await _firebaseClient
.get('$_baseUrl${type.path}.json')
.then((dynamic val) {
final List<int> ids = (val as List<dynamic>).cast<int>();
return ids;
});
return ids;
}
/// Fetch a [Story] based on its id.
Future<Story?> fetchStory({required int id}) async {
final Story? story =
await _fetchItemJson(id).then((Map<String, dynamic>? json) {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
return story;
}
/// Fetch a [Comment] based on its id.
Future<Comment?> fetchComment({required int id}) async {
final Comment? comment =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
/// Fetch a raw [Comment] based on its id.
/// The content of [Comment] will not be parsed, use this function only if
/// the format of content doesn't matter, otherwise, use [fetchComment].
Future<Comment?> fetchRawComment({required int id}) async {
final Comment? comment =
await _fetchRawItemJson(id).then((dynamic val) async {
if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
/// Fetch the parent [Story] of a [Comment]. /// Fetch the parent [Story] of a [Comment].
Future<Story?> fetchParentStory({required int id}) async { Future<Story?> fetchParentStory({required int id}) async {
Item? item; Item? item;
@ -306,7 +186,7 @@ class StoriesRepository {
/// Fetch the parent [Story] of a [Comment] as well as /// Fetch the parent [Story] of a [Comment] as well as
/// the list of [Comment] traversed in order to reach the parent. /// the list of [Comment] traversed in order to reach the parent.
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments({ Future<(Story, List<Comment>)?> fetchParentStoryWithComments({
required int id, required int id,
}) async { }) async {
Item? item; Item? item;
@ -325,12 +205,128 @@ class StoriesRepository {
parentComments[i].copyWith(level: parentComments.length - i - 1); parentComments[i].copyWith(level: parentComments.length - i - 1);
} }
return Tuple2<Story, List<Comment>>( return (
item as Story, item as Story,
parentComments.reversed.toList(), parentComments.reversed.toList(),
); );
} }
/// Fetch a list of [Comment] based on ids and return results
/// using a stream.
Stream<Comment> fetchCommentsStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??=
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
return comment;
});
if (comment != null) {
yield comment;
}
}
return;
}
/// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream.
Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??=
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
return comment;
});
if (comment != null) {
yield comment;
yield* fetchAllCommentsRecursivelyStream(
ids: comment.kids,
level: level + 1,
getFromCache: getFromCache,
);
}
}
return;
}
/// Fetch a list of [Item] based on ids and return results
/// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* {
for (final int id in ids) {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job') {
final Story story = Story.fromJson(json);
return story;
} else if (type == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
if (item != null) {
yield item;
}
}
}
/// Fetch a list of [Story] based on ids and return results
/// using a stream.
Stream<Story> fetchStoriesStream({required List<int> ids}) async* {
for (final int id in ids) {
final Story? story =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
if (story != null) {
yield story;
}
}
}
/// Fetch a list of [PollOption] based on ids and return results
/// using a stream.
Stream<PollOption> fetchPollOptionsStream({required List<int> ids}) async* {
for (final int id in ids) {
final PollOption? option =
await _fetchRawItemJson(id).then((dynamic json) async {
if (json == null) return null;
final PollOption option =
PollOption.fromJson(json as Map<String, dynamic>);
return option;
});
if (option != null) {
yield option;
}
}
}
/// Fetch a list of [Comment] based on ids recursively. /// Fetch a list of [Comment] based on ids recursively.
Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* { Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* {
for (final int id in ids) { for (final int id in ids) {
@ -343,7 +339,9 @@ class StoriesRepository {
} }
/// Parse the json of an [Item] by removing useless HTML tags. /// Parse the json of an [Item] by removing useless HTML tags.
Future<Map<String, dynamic>?> _parseJson(Map<String, dynamic>? json) async { static Future<Map<String, dynamic>?> _parseJson(
Map<String, dynamic>? json,
) async {
if (json == null) return null; if (json == null) return null;
final String text = json['text'] as String? ?? ''; final String text = json['text'] as String? ?? '';
final String parsedText = await compute<String, String>( final String parsedText = await compute<String, String>(

View File

@ -92,14 +92,12 @@ class _HomeScreenState extends State<HomeScreen>
SchedulerBinding.instance SchedulerBinding.instance
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
if (!isTesting) { FeatureDiscovery.discoverFeatures(
FeatureDiscovery.discoverFeatures( context,
context, <String>{
const <String>{ Constants.featureLogIn,
Constants.featureLogIn, },
}, );
);
}
}) })
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context); final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -212,12 +210,9 @@ class _HomeScreenState extends State<HomeScreen>
} }
void onStoryTapped(Story story, {bool isPin = false}) { void onStoryTapped(Story story, {bool isPin = false}) {
final bool showWebFirst =
context.read<PreferenceCubit>().state.webFirstEnabled;
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled; final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
final bool offlineReading = final bool offlineReading =
context.read<StoriesBloc>().state.offlineReading; context.read<StoriesBloc>().state.isOfflineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled; final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
// If a story is a job story and it has a link to the job posting, // If a story is a job story and it has a link to the job posting,
@ -227,7 +222,9 @@ class _HomeScreenState extends State<HomeScreen>
if (isJobWithLink) { if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId(); context.read<ReminderCubit>().removeLastReadStoryId();
} else { } else {
final ItemScreenArgs args = ItemScreenArgs(item: story); final ItemScreenArgs args = ItemScreenArgs(
item: story,
);
context.read<ReminderCubit>().updateLastReadStoryId(story.id); context.read<ReminderCubit>().updateLastReadStoryId(story.id);
@ -245,7 +242,7 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) { if (story.url.isNotEmpty && isJobWithLink) {
LinkUtil.launch( LinkUtil.launch(
story.url, story.url,
useReader: useReader, useReader: useReader,

View File

@ -6,8 +6,8 @@ import 'package:hacki/styles/styles.dart';
class MobileHomeScreen extends StatelessWidget { class MobileHomeScreen extends StatelessWidget {
const MobileHomeScreen({ const MobileHomeScreen({
super.key,
required this.homeScreen, required this.homeScreen,
super.key,
}); });
final Widget homeScreen; final Widget homeScreen;

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart' hide Badge; import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_slidable/flutter_slidable.dart';
@ -7,12 +6,13 @@ import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class PinnedStories extends StatelessWidget { class PinnedStories extends StatelessWidget {
const PinnedStories({ const PinnedStories({
super.key,
required this.preferenceState, required this.preferenceState,
required this.onStoryTapped, required this.onStoryTapped,
super.key,
}); });
final PreferenceState preferenceState; final PreferenceState preferenceState;
@ -32,7 +32,7 @@ class PinnedStories extends StatelessWidget {
children: <Widget>[ children: <Widget>[
SlidableAction( SlidableAction(
onPressed: (_) { onPressed: (_) {
HapticFeedback.lightImpact(); HapticFeedbackUtil.light();
context.read<PinCubit>().unpinStory(story); context.read<PinCubit>().unpinStory(story);
}, },
backgroundColor: Palette.red, backgroundColor: Palette.red,

View File

@ -8,8 +8,8 @@ import 'package:responsive_builder/responsive_builder.dart';
class TabletHomeScreen extends StatelessWidget { class TabletHomeScreen extends StatelessWidget {
const TabletHomeScreen({ const TabletHomeScreen({
super.key,
required this.homeScreen, required this.homeScreen,
super.key,
}); });
final Widget homeScreen; final Widget homeScreen;

View File

@ -2,22 +2,20 @@ import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/stories_repository.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/widgets/widgets.dart'; import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:responsive_builder/responsive_builder.dart'; import 'package:responsive_builder/responsive_builder.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class ItemScreenArgs extends Equatable { class ItemScreenArgs extends Equatable {
const ItemScreenArgs({ const ItemScreenArgs({
@ -47,10 +45,10 @@ class ItemScreenArgs extends Equatable {
class ItemScreen extends StatefulWidget { class ItemScreen extends StatefulWidget {
const ItemScreen({ const ItemScreen({
super.key,
this.splitViewEnabled = false,
required this.item, required this.item,
required this.parentComments, required this.parentComments,
super.key,
this.splitViewEnabled = false,
}); });
static const String routeName = '/item'; static const String routeName = '/item';
@ -59,14 +57,15 @@ class ItemScreen extends StatefulWidget {
return MaterialPageRoute<ItemScreen>( return MaterialPageRoute<ItemScreen>(
settings: const RouteSettings(name: routeName), settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => RepositoryProvider<CollapseCache>( builder: (BuildContext context) => RepositoryProvider<CollapseCache>(
create: (BuildContext context) => CollapseCache(), create: (_) => CollapseCache(),
lazy: false, lazy: false,
child: MultiBlocProvider( child: MultiBlocProvider(
providers: <BlocProvider<dynamic>>[ providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit( create: (BuildContext context) => CommentsCubit(
offlineReading: filterCubit: context.read<FilterCubit>(),
context.read<StoriesBloc>().state.offlineReading, isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item, item: args.item,
collapseCache: context.read<CollapseCache>(), collapseCache: context.read<CollapseCache>(),
defaultFetchMode: defaultFetchMode:
@ -75,7 +74,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order, context.read<PreferenceCubit>().state.order,
)..init( )..init(
onlyShowTargetComment: args.onlyShowTargetComment, onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments, targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache, useCommentCache: args.useCommentCache,
), ),
), ),
@ -100,15 +99,16 @@ class ItemScreen extends StatefulWidget {
} }
}, },
child: RepositoryProvider<CollapseCache>( child: RepositoryProvider<CollapseCache>(
create: (BuildContext context) => CollapseCache(), create: (_) => CollapseCache(),
lazy: false, lazy: false,
child: MultiBlocProvider( child: MultiBlocProvider(
key: ValueKey<ItemScreenArgs>(args), key: ValueKey<ItemScreenArgs>(args),
providers: <BlocProvider<dynamic>>[ providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit( create: (BuildContext context) => CommentsCubit(
offlineReading: filterCubit: context.read<FilterCubit>(),
context.read<StoriesBloc>().state.offlineReading, isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item, item: args.item,
collapseCache: context.read<CollapseCache>(), collapseCache: context.read<CollapseCache>(),
defaultFetchMode: defaultFetchMode:
@ -117,7 +117,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order, context.read<PreferenceCubit>().state.order,
)..init( )..init(
onlyShowTargetComment: args.onlyShowTargetComment, onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments, targetAncestors: args.targetComments,
), ),
), ),
], ],
@ -142,12 +142,9 @@ class ItemScreen extends StatefulWidget {
class _ItemScreenState extends State<ItemScreen> with RouteAware { class _ItemScreenState extends State<ItemScreen> with RouteAware {
final TextEditingController commentEditingController = final TextEditingController commentEditingController =
TextEditingController(); TextEditingController();
final ScrollController scrollController = ScrollController(); final ItemScrollController itemScrollController = ItemScrollController();
final RefreshController refreshController = RefreshController( final ItemPositionsListener itemPositionsListener =
initialLoadStatus: LoadStatus.idle, ItemPositionsListener.create();
initialRefreshStatus: RefreshStatus.refreshing,
);
final FocusNode focusNode = FocusNode();
final Throttle storyLinkTapThrottle = Throttle( final Throttle storyLinkTapThrottle = Throttle(
delay: _storyLinkTapThrottleDelay, delay: _storyLinkTapThrottleDelay,
); );
@ -174,16 +171,16 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
SchedulerBinding.instance SchedulerBinding.instance
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
if (!isTesting) { FeatureDiscovery.discoverFeatures(
FeatureDiscovery.discoverFeatures( context,
context, <String>{
const <String>{ Constants.featurePinToTop,
Constants.featurePinToTop, Constants.featureAddStoryToFavList,
Constants.featureAddStoryToFavList, Constants.featureOpenStoryInWebView,
Constants.featureOpenStoryInWebView, Constants.featureJumpUpButton,
}, Constants.featureJumpDownButton,
); },
} );
}) })
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context); final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -195,24 +192,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
.subscribe(this, route); .subscribe(this, route);
}); });
scrollController.addListener(() {
FocusScope.of(context).requestFocus(FocusNode());
if (commentEditingController.text.isEmpty) {
context.read<EditCubit>().onScrolled();
}
});
commentEditingController.text = context.read<EditCubit>().state.text ?? ''; commentEditingController.text = context.read<EditCubit>().state.text ?? '';
} }
@override @override
void dispose() { void dispose() {
refreshController.dispose();
commentEditingController.dispose(); commentEditingController.dispose();
scrollController.dispose();
storyLinkTapThrottle.dispose(); storyLinkTapThrottle.dispose();
featureDiscoveryDismissThrottle.dispose(); featureDiscoveryDismissThrottle.dispose();
focusNode.dispose();
super.dispose(); super.dispose();
} }
@ -227,181 +214,167 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
BlocListener<PostCubit, PostState>( BlocListener<PostCubit, PostState>(
listener: (BuildContext context, PostState postState) { listener: (BuildContext context, PostState postState) {
if (postState.status == PostStatus.successful) { if (postState.status == PostStatus.successful) {
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName,
);
final String verb = final String verb =
context.read<EditCubit>().state.replyingTo == null context.read<EditCubit>().state.replyingTo == null
? 'updated' ? 'updated'
: 'submitted'; : 'submitted';
final String msg = 'Comment $verb! ${Constants.happyFace}'; final String msg = 'Comment $verb! ${Constants.happyFace}';
focusNode.unfocus(); HapticFeedbackUtil.light();
HapticFeedback.lightImpact();
showSnackBar(content: msg); showSnackBar(content: msg);
context.read<EditCubit>().onReplySubmittedSuccessfully(); context.read<EditCubit>().onReplySubmittedSuccessfully();
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) { } else if (postState.status == PostStatus.failure) {
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName,
);
showErrorSnackBar(); showErrorSnackBar();
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} }
}, },
), ),
], ],
child: BlocListener<CommentsCubit, CommentsState>( child: BlocListener<EditCubit, EditState>(
listenWhen: (CommentsState previous, CommentsState current) => listenWhen: (EditState previous, EditState current) {
previous.status != current.status, return previous.replyingTo != current.replyingTo ||
listener: (BuildContext context, CommentsState state) { previous.itemBeingEdited != current.itemBeingEdited ||
if (state.status != CommentsStatus.loading) { commentEditingController.text != current.text;
refreshController },
..refreshCompleted() listener: (BuildContext context, EditState editState) {
..loadComplete(); if (editState.replyingTo != null ||
editState.itemBeingEdited != null) {
if (editState.text == null) {
commentEditingController.clear();
} else {
final String text = editState.text!;
commentEditingController
..text = text
..selection = TextSelection.fromPosition(
TextPosition(offset: text.length),
);
}
} else {
commentEditingController.clear();
} }
}, },
child: BlocListener<EditCubit, EditState>( child: widget.splitViewEnabled
listenWhen: (EditState previous, EditState current) { ? Material(
return previous.replyingTo != current.replyingTo || child: Stack(
previous.itemBeingEdited != current.itemBeingEdited || children: <Widget>[
commentEditingController.text != current.text; Positioned.fill(
}, child: MainView(
listener: (BuildContext context, EditState editState) { itemScrollController: itemScrollController,
if (editState.replyingTo != null || itemPositionsListener: itemPositionsListener,
editState.itemBeingEdited != null) { commentEditingController: commentEditingController,
if (editState.text == null) { authState: authState,
commentEditingController.clear(); topPadding: topPadding,
} else { splitViewEnabled: widget.splitViewEnabled,
final String text = editState.text!; onMoreTapped: onMoreTapped,
commentEditingController onRightMoreTapped: onRightMoreTapped,
..text = text onReplyTapped: showReplyBox,
..selection = TextSelection.fromPosition(
TextPosition(offset: text.length),
);
}
} else {
commentEditingController.clear();
}
},
child: widget.splitViewEnabled
? Material(
child: Stack(
children: <Widget>[
Positioned.fill(
child: MainView(
scrollController: scrollController,
refreshController: refreshController,
commentEditingController:
commentEditingController,
authState: authState,
focusNode: focusNode,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
), ),
BlocBuilder<SplitViewCubit, SplitViewState>( ),
buildWhen: ( BlocBuilder<SplitViewCubit, SplitViewState>(
SplitViewState previous, buildWhen: (
SplitViewState current, SplitViewState previous,
) => SplitViewState current,
previous.expanded != current.expanded, ) =>
builder: ( previous.expanded != current.expanded,
BuildContext context, builder: (
SplitViewState state, BuildContext context,
) { SplitViewState state,
return Positioned( ) {
top: Dimens.zero, return Positioned(
left: Dimens.zero, top: Dimens.zero,
right: Dimens.zero, left: Dimens.zero,
child: CustomAppBar( right: Dimens.zero,
backgroundColor: Theme.of(context) child: CustomAppBar(
.canvasColor backgroundColor: Theme.of(context)
.withOpacity(0.6), .canvasColor
item: widget.item, .withOpacity(0.6),
scrollController: scrollController, item: widget.item,
onBackgroundTap: onFeatureDiscoveryDismissed, splitViewEnabled: state.enabled,
onDismiss: onFeatureDiscoveryDismissed, expanded: state.expanded,
splitViewEnabled: state.enabled, onZoomTap: context.read<SplitViewCubit>().zoom,
expanded: state.expanded, onFontSizeTap: onFontSizeTapped,
onZoomTap: fontSizeIconButtonKey: fontSizeIconButtonKey,
context.read<SplitViewCubit>().zoom, ),
onFontSizeTap: onFontSizeTapped, );
fontSizeIconButtonKey: fontSizeIconButtonKey, },
), ),
); Positioned(
}, right: Dimens.pt12,
bottom: Dimens.pt36,
child: CustomFloatingActionButton(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
), ),
Positioned( ),
bottom: Dimens.zero, ],
left: Dimens.zero,
right: Dimens.zero,
child: ReplyBox(
splitViewEnabled: true,
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onCloseTapped: () {
context.read<EditCubit>().onReplyBoxClosed();
commentEditingController.clear();
focusNode.unfocus();
},
onChanged:
context.read<EditCubit>().onTextChanged,
),
),
],
),
)
: Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: CustomAppBar(
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
body: MainView(
scrollController: scrollController,
refreshController: refreshController,
commentEditingController: commentEditingController,
authState: authState,
focusNode: focusNode,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
bottomSheet: ReplyBox(
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onCloseTapped: () {
context.read<EditCubit>().onReplyBoxClosed();
commentEditingController.clear();
focusNode.unfocus();
},
onChanged: context.read<EditCubit>().onTextChanged,
),
), ),
), )
: Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: CustomAppBar(
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
item: widget.item,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
body: MainView(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
commentEditingController: commentEditingController,
authState: authState,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
onReplyTapped: showReplyBox,
),
floatingActionButton: CustomFloatingActionButton(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
),
),
), ),
); );
}, },
); );
} }
Future<bool> onFeatureDiscoveryDismissed() { void showReplyBox() {
featureDiscoveryDismissThrottle.run(() { showModalBottomSheet<void>(
HapticFeedback.lightImpact(); context: context,
ScaffoldMessenger.of(context).clearSnackBars(); isScrollControlled: true,
showSnackBar(content: 'Tap on icon to continue'); builder: (BuildContext context) {
}); return Column(
return Future<bool>.value(false); mainAxisSize: MainAxisSize.min,
children: <Widget>[
ReplyBox(
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onCloseTapped: () {
context.read<EditCubit>().onReplyBoxClosed();
commentEditingController.clear();
},
onChanged: context.read<EditCubit>().onTextChanged,
),
SizedBox(
height: MediaQuery.of(context).viewInsets.bottom,
)
],
);
},
);
} }
void onFontSizeTapped() { void onFontSizeTapped() {
@ -442,7 +415,8 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
), ),
), ),
onTap: () { onTap: () {
HapticFeedback.lightImpact(); HapticFeedbackUtil.light();
locator.get<AppReviewService>().requestReview();
context.read<PreferenceCubit>().update( context.read<PreferenceCubit>().update(
FontSizePreference(), FontSizePreference(),
to: fontSize.index, to: fontSize.index,
@ -454,45 +428,47 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
} }
void onRightMoreTapped(Comment comment) { void onRightMoreTapped(Comment comment) {
const double bottomSheetHeight = 140; HapticFeedbackUtil.light();
HapticFeedback.lightImpact();
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return Container( return SafeArea(
height: bottomSheetHeight, child: ColoredBox(
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
color: Palette.transparent, color: Palette.transparent,
child: Column( child: Column(
children: <Widget>[ mainAxisSize: MainAxisSize.min,
ListTile( children: <Widget>[
leading: const Icon(Icons.av_timer), ListTile(
title: const Text('View parents'), leading: const Icon(Icons.av_timer),
onTap: () { title: const Text('View ancestors'),
Navigator.pop(context); onTap: () {
onTimeMachineActivated(comment); Navigator.pop(context);
}, onTimeMachineActivated(comment);
enabled: },
comment.level > 0 && !(comment.dead || comment.deleted), enabled:
), comment.level > 0 && !(comment.dead || comment.deleted),
ListTile( ),
leading: const Icon(Icons.list), ListTile(
title: const Text('View in separate thread'), leading: const Icon(Icons.list),
onTap: () { title: const Text('View in separate thread'),
Navigator.pop(context); onTap: () {
goToItemScreen( locator.get<AppReviewService>().requestReview();
args: ItemScreenArgs(
item: comment, Navigator.pop(context);
useCommentCache: true, goToItemScreen(
), args: ItemScreenArgs(
forceNewScreen: true, item: comment,
); useCommentCache: true,
}, ),
enabled: !(comment.dead || comment.deleted), forceNewScreen: true,
), );
], },
enabled: !(comment.dead || comment.deleted),
),
],
),
), ),
), ),
); );
@ -513,7 +489,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
size: size, size: size,
deviceType: deviceType, deviceType: deviceType,
widthFactor: widthFactor, widthFactor: widthFactor,
onStoryLinkTapped: onStoryLinkTapped,
); );
}, },
); );

Some files were not shown because too many files have changed in this diff Show More