Compare commits

...

19 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
116 changed files with 2927 additions and 1366 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:
rules:
parameter_assignments: false

View File

@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_adaptive_back"/>
<foreground android:drawable="@mipmap/ic_launcher_adaptive_fore"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</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')
}
task clean(type: Delete) {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

Binary file not shown.

Binary file not shown.

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

@ -23,6 +23,8 @@ PODS:
- FMDB (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):
- Flutter
- OrderedSet (5.0.0)
@ -39,7 +41,7 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite (0.0.2):
- sqflite (0.0.3):
- Flutter
- FMDB (>= 2.7.5)
- synced_shared_preferences (0.0.1):
@ -62,12 +64,13 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/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`)
- 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`)
- 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`)
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@ -98,18 +101,20 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_siri_suggestions:
:path: ".symlinks/plugins/flutter_siri_suggestions/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios"
:path: ".symlinks/plugins/path_provider_foundation/darwin"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
synced_shared_preferences:
@ -124,8 +129,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/workmanager/ios"
SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
@ -133,21 +138,22 @@ SPEC CHECKSUMS:
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: 13825b8a9334a850581300559b8839134b124670
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
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 */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
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 */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
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 */; };
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 */; };
FC507E94AA7767C155787DB3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -68,14 +68,14 @@
/* End PBXCopyFilesBuildPhase 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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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; };
@ -83,8 +83,8 @@
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>"; };
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; };
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>"; };
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>"; };
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; };
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>"; };
@ -107,7 +107,7 @@
buildActionMask = 2147483647;
files = (
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */,
FC507E94AA7767C155787DB3 /* Pods_Runner.framework in Frameworks */,
7A6CD5D595D5F4E8710804C0 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -183,8 +183,8 @@
isa = PBXGroup;
children = (
E575B6F027EBC6DA002B1508 /* CloudKit.framework */,
BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */,
E530B1A7283B54DA004E8EB6 /* UniformTypeIdentifiers.framework */,
8BF0A917F40A838BF30D8F4C /* Pods_Runner.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -192,9 +192,9 @@
D79CD63C88FF49EF451AFDDF /* Pods */ = {
isa = PBXGroup;
children = (
DF5D5FFF325B7D5DFEE88A3F /* Pods-Runner.debug.xcconfig */,
4449F5D4D39C23F292D07005 /* Pods-Runner.release.xcconfig */,
027B292CC58CF92F11FC0A69 /* Pods-Runner.profile.xcconfig */,
0E63A5CE3FDBCCD054072136 /* Pods-Runner.debug.xcconfig */,
D73EA9FA5E6F35364DCA0CD1 /* Pods-Runner.release.xcconfig */,
B9EC882BDD04A309C317E416 /* Pods-Runner.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@ -229,15 +229,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
41DC8215F9CFD708C36ECBA8 /* [CP] Check Pods Manifest.lock */,
E2E6E097A94005D9196D0A71 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
7714A105B2069B720D0DF18E /* [CP] Embed Pods Frameworks */,
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */,
F1959755D5521D58CA193498 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@ -365,6 +365,7 @@
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
@ -373,7 +374,22 @@
shellPath = /bin/sh;
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;
buildActionMask = 2147483647;
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";
showEnvVarsInLog = 0;
};
7714A105B2069B720D0DF18E /* [CP] Embed Pods Frameworks */ = {
F1959755D5521D58CA193498 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -412,21 +428,6 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
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 */
/* Begin PBXSourcesBuildPhase section */
@ -565,11 +566,9 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
@ -583,7 +582,6 @@
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.jiaqi.hacki";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -707,11 +705,9 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
@ -725,7 +721,6 @@
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.jiaqi.hacki";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -780,11 +775,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist";
@ -802,7 +795,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki share extension profile";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
@ -863,11 +855,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist";
@ -884,7 +874,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki share extension profile";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -905,11 +894,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist";
@ -927,7 +914,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki action extension profile";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
@ -992,11 +978,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist";
@ -1013,7 +997,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki action extension profile";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;

View File

@ -7,7 +7,7 @@ abstract class Constants {
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
static const String hackerNewsLogoLink =
'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 appStoreLink =
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
@ -39,6 +39,8 @@ abstract class Constants {
static const String featureOpenStoryInWebView = 'open_story_in_web_view';
static const String featureLogIn = 'log_in';
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>[
'(๑•̀ㅂ•́)و✧',

View File

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

View File

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

View File

@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart';
@ -12,10 +12,11 @@ import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/linkifier_util.dart';
import 'package:hacki/utils/utils.dart';
import 'package:linkify/linkify.dart';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
part 'comments_state.dart';
@ -23,15 +24,15 @@ class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({
required FilterCubit filterCubit,
required CollapseCache collapseCache,
required bool isOfflineReading,
required Item item,
required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder,
CommentCache? commentCache,
OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository,
SembastRepository? sembastRepository,
Logger? logger,
required bool isOfflineReading,
required Item item,
required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder,
}) : _filterCubit = filterCubit,
_collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(),
@ -68,8 +69,6 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _pageSize = 20;
@override
void emit(CommentsState state) {
if (!isClosed) {
@ -117,7 +116,7 @@ class CommentsCubit extends Cubit<CommentsState> {
? item
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
item;
final List<int> kids = sortKids(updatedItem.kids);
final List<int> kids = _sortKids(updatedItem.kids);
emit(state.copyWith(item: updatedItem));
@ -132,13 +131,11 @@ class CommentsCubit extends Cubit<CommentsState> {
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
break;
case FetchMode.eager:
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
break;
}
}
@ -183,7 +180,7 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item;
final Item updatedItem =
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) {
@ -210,7 +207,7 @@ class CommentsCubit extends Cubit<CommentsState> {
}
void loadAll(Story story) {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
emit(
state.copyWith(
onlyShowTargetComment: false,
@ -221,7 +218,11 @@ class CommentsCubit extends Cubit<CommentsState> {
}
/// [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;
switch (state.fetchMode) {
@ -265,22 +266,21 @@ class CommentsCubit extends Cubit<CommentsState> {
});
_streamSubscriptions[comment.id] = streamSubscription;
break;
case FetchMode.eager:
if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading));
_streamSubscription?.resume();
_streamSubscription
?..resume()
..onData(onCommentFetched);
}
break;
}
}
Future<void> loadParentThread() async {
unawaited(HapticFeedback.lightImpact());
HapticFeedbackUtil.light();
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
final Story? parent = await _storiesRepository
.fetchParentStory(id: state.item.id)
.then(_toBuildableStory);
final Item? parent =
await _storiesRepository.fetchItem(id: state.item.parent);
if (parent == null) {
return;
@ -298,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) {
if (order == null) return;
if (state.order == order) return;
HapticFeedback.selectionClick();
HapticFeedbackUtil.selection();
_streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
s.cancel();
@ -315,7 +338,7 @@ class CommentsCubit extends Cubit<CommentsState> {
if (fetchMode == null) return;
if (state.fetchMode == fetchMode) return;
_collapseCache.resetCollapsedComments();
HapticFeedback.selectionClick();
HapticFeedbackUtil.selection();
_streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
s.cancel();
@ -325,7 +348,82 @@ class CommentsCubit extends Cubit<CommentsState> {
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) {
case CommentsOrder.natural:
return kids;
@ -361,31 +459,6 @@ class CommentsCubit extends Cubit<CommentsState> {
];
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,
),
);
}
}
}
}

View File

@ -14,6 +14,7 @@ class CommentsState extends Equatable {
required this.comments,
required this.status,
required this.fetchParentStatus,
required this.fetchRootStatus,
required this.order,
required this.fetchMode,
required this.onlyShowTargetComment,
@ -29,6 +30,7 @@ class CommentsState extends Equatable {
}) : comments = <Comment>[],
status = CommentsStatus.init,
fetchParentStatus = CommentsStatus.init,
fetchRootStatus = CommentsStatus.init,
onlyShowTargetComment = false,
currentPage = 0;
@ -36,6 +38,7 @@ class CommentsState extends Equatable {
final List<Comment> comments;
final CommentsStatus status;
final CommentsStatus fetchParentStatus;
final CommentsStatus fetchRootStatus;
final CommentsOrder order;
final FetchMode fetchMode;
final bool onlyShowTargetComment;
@ -47,6 +50,7 @@ class CommentsState extends Equatable {
List<Comment>? comments,
CommentsStatus? status,
CommentsStatus? fetchParentStatus,
CommentsStatus? fetchRootStatus,
CommentsOrder? order,
FetchMode? fetchMode,
bool? onlyShowTargetComment,
@ -57,6 +61,7 @@ class CommentsState extends Equatable {
item: item ?? this.item,
comments: comments ?? this.comments,
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
status: status ?? this.status,
order: order ?? this.order,
fetchMode: fetchMode ?? this.fetchMode,
@ -74,6 +79,7 @@ class CommentsState extends Equatable {
item,
status,
fetchParentStatus,
fetchRootStatus,
order,
fetchMode,
onlyShowTargetComment,

View File

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

View File

@ -52,8 +52,6 @@ class PreferenceState extends Equatable {
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get webFirstEnabled => _isOn<NavigationModePreference>();
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();

View File

@ -23,6 +23,12 @@ class SearchState extends Equatable {
final SearchStatus status;
final SearchParams params;
bool get hasDateFilter =>
params.filters.whereType<DateTimeRangeFilter>().isNotEmpty;
DateTimeRangeFilter? get dateFilter =>
params.filters.whereType<DateTimeRangeFilter>().singleOrNull;
SearchState copyWith({
List<Item>? results,
SearchStatus? status,

View File

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

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
@ -10,6 +9,7 @@ import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:share_plus/share_plus.dart';
extension StateExtension on State {
@ -46,7 +46,7 @@ extension StateExtension on State {
}
void onMoreTapped(Item item, Rect? rect) {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
if (item.dead || item.deleted) {
return;
@ -75,16 +75,12 @@ extension StateExtension on State {
break;
case MenuAction.fav:
onFavTapped(item);
break;
case MenuAction.share:
onShareTapped(item, rect);
break;
case MenuAction.flag:
onFlagTapped(item);
break;
case MenuAction.block:
onBlockTapped(item, isBlocked: isBlocked);
break;
case MenuAction.cancel:
break;
}

View File

@ -7,7 +7,6 @@ import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
@ -18,9 +17,9 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/custom_bloc_observer.dart';
import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/theme_util.dart';
import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:logger/logger.dart';
@ -123,14 +122,6 @@ Future<void> main({bool testing = false}) async {
systemNavigationBarDividerColor: Palette.transparent,
),
);
} else {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
statusBarColor: Colors.transparent,
),
);
}
await SystemChrome.setEnabledSystemUIMode(
@ -147,7 +138,8 @@ Future<void> main({bool testing = false}) async {
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;
@ -162,10 +154,10 @@ Future<void> main({bool testing = false}) async {
class HackiApp extends StatelessWidget {
const HackiApp({
super.key,
this.savedThemeMode,
required this.trueDarkMode,
required this.font,
super.key,
this.savedThemeMode,
});
final AdaptiveThemeMode? savedThemeMode;
@ -276,6 +268,10 @@ class HackiApp extends StatelessWidget {
AsyncSnapshot<AdaptiveThemeMode?> snapshot,
) {
final AdaptiveThemeMode? mode = snapshot.data;
ThemeUtil.updateAndroidStatusBarSetting(
Theme.of(context).brightness,
mode,
);
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen:
(PreferenceState previous, PreferenceState current) =>
@ -284,8 +280,9 @@ class HackiApp extends StatelessWidget {
final bool useTrueDark = prefState.trueDarkEnabled &&
(mode == AdaptiveThemeMode.dark ||
(mode == AdaptiveThemeMode.system &&
SchedulerBinding
.instance.window.platformBrightness ==
View.of(context)
.platformDispatcher
.platformBrightness ==
Brightness.dark));
return FeatureDiscovery(
child: MaterialApp(

View File

@ -1,10 +1,28 @@
enum Font {
roboto('Roboto'),
robotoSlab('Roboto Slab'),
robotoSlab('Roboto Slab', isSerif: true),
ubuntu('Ubuntu'),
ubuntuMono('Ubuntu Mono');
ubuntuMono('Ubuntu Mono'),
notoSerif('Noto Serif', isSerif: true);
const Font(this.label);
const Font(this.uiLabel, {this.isSerif = false});
final String label;
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

@ -35,6 +35,27 @@ class BuildableComment extends Comment with Buildable {
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;
}

View File

@ -27,6 +27,8 @@ class Comment extends Item {
String get metadata => '''by $by $timeAgo''';
bool get isRoot => level == 0;
Comment copyWith({
int? level,
bool? hidden,

View File

@ -31,7 +31,6 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const NotificationModePreference(),
const SwipeGesturePreference(),
const CollapseModePreference(),
const NavigationModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
const EyeCandyModePreference(),
@ -54,7 +53,6 @@ abstract class IntPreference extends Preference<int> {
const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _navigationModeDefaultValue = false;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _readerModeDefaultValue = true;
@ -189,29 +187,6 @@ class StoryUrlModePreference extends BooleanPreference {
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 {
const NavigationModePreference({bool? val})
: super(
val: val ?? _navigationModeDefaultValue,
);
@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 {
const ReaderModePreference({bool? val})
: super(val: val ?? _readerModeDefaultValue);

View File

@ -30,14 +30,27 @@ class DateTimeRangeFilter implements NumericFilter {
@override
String get query {
if (startTime == null || endTime == null) return '';
final int? startTimestamp = startTime == null
? null
: startTime!.toUtc().millisecondsSinceEpoch ~/ 1000;
final int? endTimestamp = endTime == null
int? endTimestamp = endTime == null
? null
: 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 =
'''${startTimestamp == null ? '' : 'created_at_i>$startTimestamp'},${endTimestamp == null ? '' : 'created_at_i<$endTimestamp'}''';
'''created_at_i>=$startTimestamp, created_at_i<=$endTimestamp''';
if (query.endsWith(',')) {
return query.replaceFirst(',', '');

View File

@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:tuple/tuple.dart';
/// [StoriesRepository] is for fetching
/// [Item] such as [Story], [PollOption], [Comment] or [User].
@ -187,7 +186,7 @@ class StoriesRepository {
/// Fetch the parent [Story] of a [Comment] as well as
/// 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,
}) async {
Item? item;
@ -206,7 +205,7 @@ class StoriesRepository {
parentComments[i].copyWith(level: parentComments.length - i - 1);
}
return Tuple2<Story, List<Comment>>(
return (
item as Story,
parentComments.reversed.toList(),
);

View File

@ -210,12 +210,9 @@ class _HomeScreenState extends State<HomeScreen>
}
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 offlineReading =
context.read<StoriesBloc>().state.isOfflineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
// If a story is a job story and it has a link to the job posting,
@ -245,7 +242,7 @@ class _HomeScreenState extends State<HomeScreen>
}
}
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) {
if (story.url.isNotEmpty && isJobWithLink) {
LinkUtil.launch(
story.url,
useReader: useReader,

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@ import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
@ -15,8 +14,8 @@ import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.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:scrollable_positioned_list/scrollable_positioned_list.dart';
class ItemScreenArgs extends Equatable {
const ItemScreenArgs({
@ -46,10 +45,10 @@ class ItemScreenArgs extends Equatable {
class ItemScreen extends StatefulWidget {
const ItemScreen({
super.key,
this.splitViewEnabled = false,
required this.item,
required this.parentComments,
super.key,
this.splitViewEnabled = false,
});
static const String routeName = '/item';
@ -143,12 +142,9 @@ class ItemScreen extends StatefulWidget {
class _ItemScreenState extends State<ItemScreen> with RouteAware {
final TextEditingController commentEditingController =
TextEditingController();
final ScrollController scrollController = ScrollController();
final RefreshController refreshController = RefreshController(
initialLoadStatus: LoadStatus.idle,
initialRefreshStatus: RefreshStatus.refreshing,
);
final FocusNode focusNode = FocusNode();
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
final Throttle storyLinkTapThrottle = Throttle(
delay: _storyLinkTapThrottleDelay,
);
@ -181,6 +177,8 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
Constants.featureJumpUpButton,
Constants.featureJumpDownButton,
},
);
})
@ -194,24 +192,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
.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 ?? '';
}
@override
void dispose() {
refreshController.dispose();
commentEditingController.dispose();
scrollController.dispose();
storyLinkTapThrottle.dispose();
featureDiscoveryDismissThrottle.dispose();
focusNode.dispose();
super.dispose();
}
@ -226,166 +214,169 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
BlocListener<PostCubit, PostState>(
listener: (BuildContext context, PostState postState) {
if (postState.status == PostStatus.successful) {
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName,
);
final String verb =
context.read<EditCubit>().state.replyingTo == null
? 'updated'
: 'submitted';
final String msg = 'Comment $verb! ${Constants.happyFace}';
focusNode.unfocus();
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
showSnackBar(content: msg);
context.read<EditCubit>().onReplySubmittedSuccessfully();
context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) {
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName,
);
showErrorSnackBar();
context.read<PostCubit>().reset();
}
},
),
],
child: BlocListener<CommentsCubit, CommentsState>(
listenWhen: (CommentsState previous, CommentsState current) =>
previous.status != current.status,
listener: (BuildContext context, CommentsState state) {
if (state.status != CommentsStatus.loading) {
refreshController
..refreshCompleted()
..loadComplete();
child: BlocListener<EditCubit, EditState>(
listenWhen: (EditState previous, EditState current) {
return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited ||
commentEditingController.text != current.text;
},
listener: (BuildContext context, EditState editState) {
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>(
listenWhen: (EditState previous, EditState current) {
return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited ||
commentEditingController.text != current.text;
},
listener: (BuildContext context, EditState editState) {
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: 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,
onRightMoreTapped: onRightMoreTapped,
),
child: widget.splitViewEnabled
? Material(
child: Stack(
children: <Widget>[
Positioned.fill(
child: MainView(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
commentEditingController: commentEditingController,
authState: authState,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
onReplyTapped: showReplyBox,
),
BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (
SplitViewState previous,
SplitViewState current,
) =>
previous.expanded != current.expanded,
builder: (
BuildContext context,
SplitViewState state,
) {
return Positioned(
top: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: CustomAppBar(
backgroundColor: Theme.of(context)
.canvasColor
.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
splitViewEnabled: state.enabled,
expanded: state.expanded,
onZoomTap:
context.read<SplitViewCubit>().zoom,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
);
},
),
BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (
SplitViewState previous,
SplitViewState current,
) =>
previous.expanded != current.expanded,
builder: (
BuildContext context,
SplitViewState state,
) {
return Positioned(
top: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: CustomAppBar(
backgroundColor: Theme.of(context)
.canvasColor
.withOpacity(0.6),
item: widget.item,
splitViewEnabled: state.enabled,
expanded: state.expanded,
onZoomTap: 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,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
body: MainView(
scrollController: scrollController,
refreshController: refreshController,
commentEditingController: commentEditingController,
authState: authState,
focusNode: focusNode,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
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,
),
),
),
);
},
);
}
void showReplyBox() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return Column(
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() {
const Offset offset = Offset.zero;
final RenderBox overlay =
@ -424,7 +415,8 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
),
),
onTap: () {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
locator.get<AppReviewService>().requestReview();
context.read<PreferenceCubit>().update(
FontSizePreference(),
to: fontSize.index,
@ -436,7 +428,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
}
void onRightMoreTapped(Comment comment) {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
@ -462,6 +454,8 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
leading: const Icon(Icons.list),
title: const Text('View in separate thread'),
onTap: () {
locator.get<AppReviewService>().requestReview();
Navigator.pop(context);
goToItemScreen(
args: ItemScreenArgs(

View File

@ -1,18 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class CustomAppBar extends AppBar {
CustomAppBar({
super.key,
required ScrollController scrollController,
required Item item,
required Color super.backgroundColor,
required super.backgroundColor,
required VoidCallback onFontSizeTap,
required GlobalKey fontSizeIconButtonKey,
super.key,
bool splitViewEnabled = false,
VoidCallback? onZoomTap,
bool? expanded,
@ -28,15 +27,12 @@ class CustomAppBar extends AppBar {
size: TextDimens.pt20,
),
onPressed: () {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
onZoomTap?.call();
},
),
const Spacer(),
],
ScrollUpIconButton(
scrollController: scrollController,
),
IconButton(
key: fontSizeIconButtonKey,
icon: Text(

View File

@ -0,0 +1,99 @@
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/custom_described_feature_overlay.dart';
import 'package:hacki/styles/palette.dart';
import 'package:hacki/utils/utils.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CustomFloatingActionButton extends StatelessWidget {
const CustomFloatingActionButton({
required this.itemScrollController,
required this.itemPositionsListener,
super.key,
});
final ItemScrollController itemScrollController;
final ItemPositionsListener itemPositionsListener;
@override
Widget build(BuildContext context) {
return BlocBuilder<CommentsCubit, CommentsState>(
builder: (BuildContext context, CommentsState state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
CustomDescribedFeatureOverlay(
featureId: Constants.featureJumpUpButton,
contentLocation: ContentLocation.above,
tapTarget: const Icon(
Icons.keyboard_arrow_up,
color: Palette.white,
),
title: const Text('Jump to previous root level comment.'),
description: const Text(
'''Tapping on this button will take you to the previous off-screen root level comment.''',
),
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
/// Randomly generated string as heroTag to prevent
/// default [FloatingActionButton] animation.
heroTag: UniqueKey().hashCode,
onPressed: () {
if (state.status == CommentsStatus.loading) return;
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().jumpUp(
itemScrollController,
itemPositionsListener,
);
},
child: Icon(
Icons.keyboard_arrow_up,
color: state.status == CommentsStatus.loading
? Palette.grey
: Theme.of(context).colorScheme.primary,
),
),
),
CustomDescribedFeatureOverlay(
featureId: Constants.featureJumpDownButton,
tapTarget: const Icon(
Icons.keyboard_arrow_down,
color: Palette.white,
),
title: const Text('Jump to next root level comment.'),
description: const Text(
'''Tapping on this button will take you to the next off-screen root level comment.''',
),
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
/// Same as above.
heroTag: UniqueKey().hashCode,
onPressed: () {
if (state.status == CommentsStatus.loading) return;
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().jump(
itemScrollController,
itemPositionsListener,
);
},
child: Icon(
Icons.keyboard_arrow_down,
color: state.status == CommentsStatus.loading
? Palette.grey
: Theme.of(context).colorScheme.primary,
),
),
),
],
);
},
);
}
}

View File

@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class FavIconButton extends StatelessWidget {
const FavIconButton({
super.key,
required this.storyId,
super.key,
});
final int storyId;
@ -38,7 +38,7 @@ class FavIconButton extends StatelessWidget {
),
),
onPressed: () {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
if (isFav) {
context.read<FavCubit>().removeFav(storyId);
} else {

View File

@ -6,8 +6,8 @@ import 'package:hacki/utils/utils.dart';
class LinkIconButton extends StatelessWidget {
const LinkIconButton({
super.key,
required this.storyId,
super.key,
});
final int storyId;

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@ -12,31 +13,31 @@ import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class MainView extends StatelessWidget {
const MainView({
super.key,
required this.scrollController,
required this.refreshController,
required this.itemScrollController,
required this.itemPositionsListener,
required this.commentEditingController,
required this.authState,
required this.focusNode,
required this.topPadding,
required this.splitViewEnabled,
required this.onMoreTapped,
required this.onRightMoreTapped,
required this.onReplyTapped,
super.key,
});
final ScrollController scrollController;
final RefreshController refreshController;
final ItemScrollController itemScrollController;
final ItemPositionsListener itemPositionsListener;
final TextEditingController commentEditingController;
final AuthState authState;
final FocusNode focusNode;
final double topPadding;
final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<Comment> onRightMoreTapped;
final VoidCallback onReplyTapped;
static const int _loadingIndicatorOpacityAnimationDuration = 300;
static const double _trailingBoxHeight = 240;
@ -48,131 +49,100 @@ class MainView extends StatelessWidget {
Positioned.fill(
child: BlocBuilder<CommentsCubit, CommentsState>(
builder: (BuildContext context, CommentsState state) {
return SmartRefresher(
scrollController: scrollController,
enablePullUp: !state.onlyShowTargetComment,
enablePullDown: !state.onlyShowTargetComment,
header: WaterDropMaterialHeader(
backgroundColor: Palette.orange,
offset: topPadding,
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
builder: (BuildContext context, LoadStatus? mode) {
const double height = 55;
late final Widget body;
return Scrollbar(
interactive: true,
child: RefreshIndicator(
displacement: 100,
onRefresh: () async {
HapticFeedbackUtil.light();
if (mode == LoadStatus.idle) {
body = const Text('');
} else if (mode == LoadStatus.loading) {
body = const Text('');
} else if (mode == LoadStatus.failed) {
body = const Text(
'',
);
} else if (mode == LoadStatus.canLoading) {
body = const Text(
'',
);
} else {
body = const Text('');
}
return SizedBox(
height: height,
child: Center(child: body),
);
},
),
controller: refreshController,
onRefresh: () {
HapticFeedback.lightImpact();
if (context.read<StoriesBloc>().state.isOfflineReading ==
false &&
state.onlyShowTargetComment == false) {
unawaited(context.read<CommentsCubit>().refresh());
if (context.read<StoriesBloc>().state.isOfflineReading) {
refreshController.refreshCompleted();
} else {
context.read<CommentsCubit>().refresh();
if (state.item.isPoll) {
context.read<PollCubit>().refresh();
}
}
},
onLoading: () {
if (state.fetchMode == FetchMode.eager) {
context.read<CommentsCubit>().loadMore();
} else {
refreshController.loadComplete();
}
},
child: ListView.builder(
primary: false,
itemCount: state.comments.length + 2,
itemBuilder: (BuildContext context, int index) {
if (index == 0) {
return _ParentItemSection(
scrollController: scrollController,
refreshController: refreshController,
commentEditingController: commentEditingController,
state: state,
authState: authState,
focusNode: focusNode,
topPadding: topPadding,
splitViewEnabled: splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
);
} else if (index == state.comments.length + 1) {
if ((state.status == CommentsStatus.allLoaded &&
state.comments.isNotEmpty) ||
state.onlyShowTargetComment) {
return SizedBox(
height: _trailingBoxHeight,
child: Center(
child: Text(Constants.happyFace),
),
);
} else {
return const SizedBox.shrink();
if (state.item.isPoll) {
context.read<PollCubit>().refresh();
}
}
index = index - 1;
final Comment comment = state.comments.elementAt(index);
return FadeIn(
key: ValueKey<String>('${comment.id}-FadeIn'),
child: CommentTile(
comment: comment,
level: comment.level,
opUsername: state.item.by,
fetchMode: state.fetchMode,
onReplyTapped: (Comment cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
if (cmt.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(cmt);
focusNode.requestFocus();
},
onEditTapped: (Comment cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
context.read<EditCubit>().onEditTapped(cmt);
focusNode.requestFocus();
},
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
),
);
},
child: ScrollablePositionedList.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemCount: state.comments.length + 2,
padding: EdgeInsets.only(top: topPadding),
itemBuilder: (BuildContext context, int index) {
if (index == 0) {
return _ParentItemSection(
commentEditingController: commentEditingController,
state: state,
authState: authState,
topPadding: topPadding,
splitViewEnabled: splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
onReplyTapped: onReplyTapped,
);
} else if (index == state.comments.length + 1) {
if ((state.status == CommentsStatus.allLoaded &&
state.comments.isNotEmpty) ||
state.onlyShowTargetComment) {
return SizedBox(
height: _trailingBoxHeight,
child: Center(
child: Text(Constants.happyFace),
),
);
} else {
return const SizedBox.shrink();
}
}
index = index - 1;
final Comment comment = state.comments.elementAt(index);
return FadeIn(
key: ValueKey<String>('${comment.id}-FadeIn'),
child: CommentTile(
comment: comment,
level: comment.level,
opUsername: state.item.by,
fetchMode: state.fetchMode,
onReplyTapped: (Comment cmt) {
HapticFeedbackUtil.light();
if (cmt.deleted || cmt.dead) {
return;
}
if (cmt.id !=
context
.read<EditCubit>()
.state
.replyingTo
?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(cmt);
onReplyTapped();
},
onEditTapped: (Comment cmt) {
HapticFeedbackUtil.light();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
context.read<EditCubit>().onEditTapped(cmt);
onReplyTapped();
},
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
),
);
},
),
),
);
},
@ -198,7 +168,7 @@ class MainView extends StatelessWidget {
);
},
),
)
),
],
);
}
@ -206,28 +176,27 @@ class MainView extends StatelessWidget {
class _ParentItemSection extends StatelessWidget {
const _ParentItemSection({
required this.scrollController,
required this.refreshController,
required this.commentEditingController,
required this.state,
required this.authState,
required this.focusNode,
required this.topPadding,
required this.splitViewEnabled,
required this.onMoreTapped,
required this.onRightMoreTapped,
required this.onReplyTapped,
});
final ScrollController scrollController;
final RefreshController refreshController;
final TextEditingController commentEditingController;
final CommentsState state;
final AuthState authState;
final FocusNode focusNode;
final double topPadding;
final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<Comment> onRightMoreTapped;
final VoidCallback onReplyTapped;
static const double _viewParentButtonWidth = 100;
static const double _viewRootButtonWidth = 80;
@override
Widget build(BuildContext context) {
@ -236,168 +205,170 @@ class _ParentItemSection extends StatelessWidget {
'''Posted by ${state.item.by} ${state.item.timeAgo}, ${state.item.title}. ${state.item.text}''',
child: Column(
children: <Widget>[
SizedBox(
height: topPadding,
),
if (!splitViewEnabled)
const Padding(
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
DeviceGestureWrapper(
child: Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
focusNode.requestFocus();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
onReplyTapped();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
child: Row(
children: <Widget>[
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
),
const Spacer(),
Text(
state.item.timeAgo,
style: const TextStyle(
color: Palette.grey,
),
),
],
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
),
child: Row(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
.isOfflineReading,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
),
const Spacer(),
Text(
state.item.timeAgo,
style: const TextStyle(
color: Palette.grey,
),
),
],
),
),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
.isOfflineReading,
),
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
?.color,
),
children: <TextSpan>[
TextSpan(
semanticsLabel: state.item.title,
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
),
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
?.color,
),
if (state.item.url.isNotEmpty)
children: <TextSpan>[
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
semanticsLabel: state.item.title,
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
fontSize: prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
),
],
if (state.item.url.isNotEmpty)
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
),
),
],
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
FadeIn(
child: SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: ItemText(
item: state.item,
),
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: ItemText(
item: state.item,
),
),
),
],
);
},
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: const PollView(),
],
);
},
),
],
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: const PollView(),
),
],
),
),
),
if (state.item.text.isNotEmpty)
@ -435,22 +406,45 @@ class _ParentItemSection extends StatelessWidget {
const SizedBox(
width: Dimens.pt4,
),
TextButton(
onPressed: context.read<CommentsCubit>().loadParentThread,
child: state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
SizedBox(
width: _viewParentButtonWidth,
child: TextButton(
onPressed: context.read<CommentsCubit>().loadParentThread,
child: state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
)
: const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
SizedBox(
width: _viewRootButtonWidth,
child: TextButton(
onPressed: context.read<CommentsCubit>().loadRootThread,
child: state.fetchRootStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View root',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
),
),
),
],
const Spacer(),

View File

@ -2,21 +2,23 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class MorePopupMenu extends StatelessWidget {
const MorePopupMenu({
super.key,
required this.item,
required this.isBlocked,
required this.onLoginTapped,
super.key,
});
final Item item;
@ -99,10 +101,10 @@ class MorePopupMenu extends StatelessWidget {
'About ${state.user.id}',
),
content: state.user.about.isEmpty
? Row(
? const Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: const <Widget>[
children: <Widget>[
Text(
'empty',
style: TextStyle(
@ -125,6 +127,9 @@ class MorePopupMenu extends StatelessWidget {
actions: <Widget>[
TextButton(
onPressed: () {
locator
.get<AppReviewService>()
.requestReview();
Navigator.pop(context);
onSearchUserTapped(context);
},
@ -133,7 +138,12 @@ class MorePopupMenu extends StatelessWidget {
),
),
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () {
locator
.get<AppReviewService>()
.requestReview();
Navigator.pop(context);
},
child: const Text(
'Okay',
),

View File

@ -1,18 +1,18 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class PinIconButton extends StatelessWidget {
const PinIconButton({
super.key,
required this.story,
super.key,
});
final Story story;
@ -49,7 +49,7 @@ class PinIconButton extends StatelessWidget {
),
),
onPressed: () {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
if (pinned) {
context.read<PinCubit>().unpinStory(story);
} else {

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
@ -7,6 +6,7 @@ import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class PollView extends StatefulWidget {
const PollView({super.key});
@ -100,7 +100,7 @@ class _PollViewState extends State<PollView> {
children: <Widget>[
IconButton(
onPressed: () {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
context.read<VoteCubit>().upvote();
},
icon: Icon(

View File

@ -1,6 +1,5 @@
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:hacki/cubits/cubits.dart';
@ -9,20 +8,19 @@ import 'package:hacki/models/item/item.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class ReplyBox extends StatefulWidget {
const ReplyBox({
super.key,
this.splitViewEnabled = false,
required this.focusNode,
required this.textEditingController,
required this.onSendTapped,
required this.onCloseTapped,
required this.onChanged,
super.key,
this.splitViewEnabled = false,
});
final bool splitViewEnabled;
final FocusNode focusNode;
final TextEditingController textEditingController;
final VoidCallback onSendTapped;
final VoidCallback onCloseTapped;
@ -47,206 +45,203 @@ class _ReplyBoxState extends State<ReplyBox> {
previous.itemBeingEdited != current.itemBeingEdited ||
previous.replyingTo != current.replyingTo,
builder: (BuildContext context, EditState editState) {
return Visibility(
visible: editState.showReplyBox,
child: BlocBuilder<PostCubit, PostState>(
builder: (BuildContext context, PostState postState) {
final Item? replyingTo = editState.replyingTo;
final bool isLoading = postState.status == PostStatus.loading;
return BlocBuilder<PostCubit, PostState>(
builder: (BuildContext context, PostState postState) {
final Item? replyingTo = editState.replyingTo;
final bool isLoading = postState.status == PostStatus.loading;
return Padding(
padding: EdgeInsets.only(
bottom: expanded
? Dimens.zero
: widget.splitViewEnabled
? MediaQuery.of(context).viewInsets.bottom
: Dimens.zero,
return Padding(
padding: EdgeInsets.only(
bottom: expanded
? Dimens.zero
: widget.splitViewEnabled
? MediaQuery.of(context).viewInsets.bottom
: Dimens.zero,
),
child: AnimatedContainer(
height: expanded ? expandedHeight : _collapsedHeight,
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
boxShadow: <BoxShadow>[
if (!context.read<SplitViewCubit>().state.enabled)
BoxShadow(
color: expanded ? Palette.transparent : Palette.black26,
blurRadius: Dimens.pt40,
),
],
),
child: AnimatedContainer(
height: expanded ? expandedHeight : _collapsedHeight,
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
boxShadow: <BoxShadow>[
if (!context.read<SplitViewCubit>().state.enabled)
BoxShadow(
color:
expanded ? Palette.transparent : Palette.black26,
blurRadius: Dimens.pt40,
child: Material(
child: Column(
children: <Widget>[
if (context.read<SplitViewCubit>().state.enabled)
const Divider(
height: Dimens.zero,
),
],
),
child: Material(
child: Column(
children: <Widget>[
if (context.read<SplitViewCubit>().state.enabled)
const Divider(
height: Dimens.zero,
),
AnimatedContainer(
height: expanded ? Dimens.pt36 : Dimens.zero,
duration: const Duration(milliseconds: 200),
),
Row(
children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt12,
top: Dimens.pt8,
bottom: Dimens.pt8,
),
child: Text(
replyingTo == null
? 'Editing'
: 'Replying '
'${replyingTo.by}',
style: const TextStyle(color: Palette.grey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
AnimatedContainer(
height: expanded ? Dimens.pt36 : Dimens.zero,
duration: const Duration(milliseconds: 200),
),
Row(
children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt12,
top: Dimens.pt8,
bottom: Dimens.pt8,
),
child: Text(
replyingTo == null
? 'Editing'
: 'Replying to '
'${replyingTo.by}',
style: const TextStyle(color: Palette.grey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (!isLoading) ...<Widget>[
...<Widget>[
if (replyingTo != null)
AnimatedOpacity(
opacity:
expanded ? NumSwitch.on : NumSwitch.off,
duration: const Duration(milliseconds: 300),
child: IconButton(
key: const Key('quote'),
icon: const Icon(
FeatherIcons.code,
color: Palette.orange,
size: TextDimens.pt18,
),
onPressed:
expanded ? showTextPopup : null,
),
if (!isLoading) ...<Widget>[
...<Widget>[
if (replyingTo != null)
AnimatedOpacity(
opacity:
expanded ? NumSwitch.on : NumSwitch.off,
duration: const Duration(milliseconds: 300),
child: IconButton(
key: const Key('quote'),
icon: const Icon(
FeatherIcons.code,
color: Palette.orange,
size: TextDimens.pt18,
),
onPressed: expanded ? showTextPopup : null,
),
IconButton(
key: const Key('expand'),
icon: Icon(
expanded
? FeatherIcons.minimize2
: FeatherIcons.maximize2,
color: Palette.orange,
size: TextDimens.pt18,
),
onPressed: () {
setState(() {
expanded = !expanded;
});
},
),
],
IconButton(
key: const Key('close'),
icon: const Icon(
Icons.close,
key: const Key('expand'),
icon: Icon(
expanded
? FeatherIcons.minimize2
: FeatherIcons.maximize2,
color: Palette.orange,
size: TextDimens.pt18,
),
onPressed: () {
final EditState state =
context.read<EditCubit>().state;
if (state.replyingTo != null &&
state.text.isNotNullOrEmpty) {
showDialog<void>(
context: context,
builder: (BuildContext context) =>
AlertDialog(
title: const Text('Save draft?'),
actions: <Widget>[
TextButton(
onPressed: () {
context
.read<EditCubit>()
.deleteDraft();
Navigator.pop(context);
},
child: const Text(
'No',
style: TextStyle(
color: Palette.red,
),
),
),
TextButton(
onPressed: () =>
Navigator.pop(context),
child: const Text('Yes'),
),
],
),
);
}
widget.onCloseTapped();
expanded = false;
setState(() {
expanded = !expanded;
});
},
),
],
if (isLoading)
const Padding(
padding: EdgeInsets.symmetric(
vertical: Dimens.pt12,
horizontal: Dimens.pt16,
),
child: SizedBox(
height: Dimens.pt24,
width: Dimens.pt24,
child: CircularProgressIndicator(
color: Palette.orange,
strokeWidth: Dimens.pt2,
),
),
)
else
IconButton(
key: const Key('send'),
icon: const Icon(
Icons.send,
color: Palette.orange,
),
onPressed: () {
widget.onSendTapped();
expanded = false;
},
IconButton(
key: const Key('close'),
icon: const Icon(
Icons.close,
color: Palette.orange,
),
onPressed: () {
Navigator.pop(context);
final EditState state =
context.read<EditCubit>().state;
if (state.replyingTo != null &&
state.text.isNotNullOrEmpty) {
showDialog<void>(
context: context,
builder: (BuildContext context) =>
AlertDialog(
title: const Text('Save draft?'),
actions: <Widget>[
TextButton(
onPressed: () {
context
.read<EditCubit>()
.deleteDraft();
Navigator.pop(context);
},
child: const Text(
'No',
style: TextStyle(
color: Palette.red,
),
),
),
TextButton(
onPressed: () =>
Navigator.pop(context),
child: const Text('Yes'),
),
],
),
);
}
widget.onCloseTapped();
expanded = false;
},
),
],
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt16,
),
child: TextField(
focusNode: widget.focusNode,
controller: widget.textEditingController,
maxLines: 100,
decoration: const InputDecoration(
alignLabelWithHint: true,
contentPadding: EdgeInsets.zero,
hintText: '...',
hintStyle: TextStyle(
color: Palette.grey,
),
focusedBorder: InputBorder.none,
border: InputBorder.none,
if (isLoading)
const Padding(
padding: EdgeInsets.symmetric(
vertical: Dimens.pt12,
horizontal: Dimens.pt16,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
textInputAction: TextInputAction.newline,
onChanged: widget.onChanged,
child: SizedBox(
height: Dimens.pt24,
width: Dimens.pt24,
child: CircularProgressIndicator(
color: Palette.orange,
strokeWidth: Dimens.pt2,
),
),
)
else
IconButton(
key: const Key('send'),
icon: const Icon(
Icons.send,
color: Palette.orange,
),
onPressed: () {
widget.onSendTapped();
expanded = false;
},
),
],
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt16,
),
child: TextField(
autofocus: true,
controller: widget.textEditingController,
maxLines: 100,
decoration: const InputDecoration(
alignLabelWithHint: true,
contentPadding: EdgeInsets.zero,
hintText: '...',
hintStyle: TextStyle(
color: Palette.grey,
),
focusedBorder: InputBorder.none,
border: InputBorder.none,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
textInputAction: TextInputAction.newline,
onChanged: widget.onChanged,
),
),
],
),
),
],
),
),
);
},
),
),
);
},
);
},
);
@ -296,7 +291,7 @@ class _ReplyBoxState extends State<ReplyBox> {
),
),
onPressed: () {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
setState(() {
expanded = false;
});
@ -324,7 +319,7 @@ class _ReplyBoxState extends State<ReplyBox> {
),
onPressed: () => FlutterClipboard.copy(
replyingTo.text,
).then((_) => HapticFeedback.selectionClick()),
).then((_) => HapticFeedbackUtil.selection()),
),
IconButton(
icon: const Icon(

View File

@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:hacki/styles/styles.dart';
class ScrollUpIconButton extends StatefulWidget {
const ScrollUpIconButton({
super.key,
required this.scrollController,
});
final ScrollController scrollController;
@override
_ScrollUpIconButtonState createState() => _ScrollUpIconButtonState();
}
class _ScrollUpIconButtonState extends State<ScrollUpIconButton> {
@override
void initState() {
super.initState();
widget.scrollController.addListener(() {
if (widget.scrollController.offset <= 1000 && mounted) {
setState(() {});
}
});
}
@override
Widget build(BuildContext context) {
if (widget.scrollController.hasClients) {
final double curPos = widget.scrollController.offset;
final double opacity = curPos / 1000;
return Opacity(
opacity: opacity.clamp(0, 1),
child: IconButton(
tooltip: 'Scroll to top',
icon: const Icon(
FeatherIcons.chevronsUp,
color: Palette.orange,
size: TextDimens.pt26,
),
onPressed: () {
final double curPos = widget.scrollController.offset;
widget.scrollController.animateTo(
0,
curve: Curves.bounceOut,
duration: Duration(
milliseconds: curPos ~/ 15,
),
);
},
),
);
}
return Container();
}
}

View File

@ -8,11 +8,11 @@ import 'package:responsive_builder/responsive_builder.dart';
class TimeMachineDialog extends StatelessWidget {
const TimeMachineDialog({
super.key,
required this.comment,
required this.size,
required this.deviceType,
required this.widthFactor,
super.key,
});
final Comment comment;

View File

@ -1,4 +1,5 @@
export 'custom_app_bar.dart';
export 'custom_floating_action_button.dart';
export 'fav_icon_button.dart';
export 'link_icon_button.dart';
export 'login_dialog.dart';
@ -7,5 +8,4 @@ export 'more_popup_menu.dart';
export 'pin_icon_button.dart';
export 'poll_view.dart';
export 'reply_box.dart';
export 'scroll_up_icon_button.dart';
export 'time_machine_dialog.dart';

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
@ -16,7 +15,6 @@ import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:tuple/tuple.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@ -102,7 +100,7 @@ class _ProfileScreenState extends State<ProfileScreen>
.where((Item e) => !e.dead && !e.deleted)
.toList(),
onRefresh: () {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
context.read<HistoryCubit>().refresh();
},
onLoadMore: () {
@ -166,7 +164,7 @@ class _ProfileScreenState extends State<ProfileScreen>
refreshController: refreshControllerFav,
items: favState.favItems,
onRefresh: () {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
context.read<FavCubit>().refresh();
},
onLoadMore: () {
@ -221,7 +219,7 @@ class _ProfileScreenState extends State<ProfileScreen>
context.read<NotificationCubit>().loadMore();
},
onRefresh: () {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
context.read<NotificationCubit>().refresh();
},
),
@ -354,16 +352,18 @@ class _ProfileScreenState extends State<ProfileScreen>
locator
.get<StoriesRepository>()
.fetchParentStoryWithComments(id: comment.parent)
.then((Tuple2<Story, List<Comment>>? tuple) {
if (tuple != null && mounted) {
.then(((Story, List<Comment>)? res) {
if (res != null && mounted) {
final Story parent = res.$1;
final List<Comment> children = res.$2;
goToItemScreen(
args: ItemScreenArgs(
item: tuple.item1,
targetComments: tuple.item2.isEmpty
item: parent,
targetComments: children.isEmpty
? <Comment>[comment]
: <Comment>[
...tuple.item2,
comment.copyWith(level: tuple.item2.length)
...children,
comment.copyWith(level: children.length)
],
onlyShowTargetComment: true,
),

View File

@ -3,8 +3,8 @@ import 'package:hacki/styles/styles.dart';
class CenteredMessageView extends StatelessWidget {
const CenteredMessageView({
super.key,
required this.content,
super.key,
});
final String content;

View File

@ -8,7 +8,6 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class InboxView extends StatelessWidget {
const InboxView({
super.key,
required this.refreshController,
required this.comments,
required this.unreadCommentsIds,
@ -16,6 +15,7 @@ class InboxView extends StatelessWidget {
required this.onMarkAllAsReadTapped,
required this.onLoadMore,
required this.onRefresh,
super.key,
});
final RefreshController refreshController;

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_email_sender/flutter_email_sender.dart';
@ -28,10 +27,10 @@ import 'package:share_plus/share_plus.dart';
class Settings extends StatefulWidget {
const Settings({
super.key,
required this.authState,
required this.magicWord,
required this.pageType,
super.key,
});
final AuthState authState;
@ -75,13 +74,13 @@ class _SettingsState extends State<Settings> {
const SizedBox(
height: Dimens.pt8,
),
Flex(
const Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Row(
children: const <Widget>[
children: <Widget>[
SizedBox(
width: Dimens.pt16,
),
@ -92,7 +91,7 @@ class _SettingsState extends State<Settings> {
),
Flexible(
child: Row(
children: const <Widget>[
children: <Widget>[
Text('Default comments order'),
Spacer(),
],
@ -129,7 +128,7 @@ class _SettingsState extends State<Settings> {
.toList(),
onChanged: (FetchMode? fetchMode) {
if (fetchMode != null) {
HapticFeedback.selectionClick();
HapticFeedbackUtil.selection();
context.read<PreferenceCubit>().update(
FetchModePreference(),
to: fetchMode.index,
@ -163,7 +162,7 @@ class _SettingsState extends State<Settings> {
.toList(),
onChanged: (CommentsOrder? order) {
if (order != null) {
HapticFeedback.selectionClick();
HapticFeedbackUtil.selection();
context.read<PreferenceCubit>().update(
CommentsOrderPreference(),
to: order.index,
@ -202,7 +201,7 @@ class _SettingsState extends State<Settings> {
preference as BooleanPreference,
),
onChanged: (bool val) {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
context
.read<PreferenceCubit>()
@ -335,12 +334,12 @@ class _SettingsState extends State<Settings> {
}
},
title: Text(
font.label,
font.uiLabel,
style: TextStyle(fontFamily: font.name),
),
),
Row(
children: const <Widget>[
const Row(
children: <Widget>[
Text(
'*Restart required',
style: TextStyle(
@ -372,22 +371,19 @@ class _SettingsState extends State<Settings> {
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.light,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setLight(),
onChanged: updateThemeSetting,
title: const Text('Light'),
),
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.dark,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setDark(),
onChanged: updateThemeSetting,
title: const Text('Dark'),
),
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.system,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setSystem(),
onChanged: updateThemeSetting,
title: const Text('System'),
),
],
@ -397,6 +393,21 @@ class _SettingsState extends State<Settings> {
);
}
void updateThemeSetting(AdaptiveThemeMode? val) {
switch (val) {
case AdaptiveThemeMode.light:
AdaptiveTheme.of(context).setLight();
case AdaptiveThemeMode.dark:
AdaptiveTheme.of(context).setDark();
case AdaptiveThemeMode.system:
case null:
AdaptiveTheme.of(context).setSystem();
}
final Brightness brightness = Theme.of(context).brightness;
ThemeUtil.updateAndroidStatusBarSetting(brightness, val);
}
void showClearCacheDialog() {
showDialog<void>(
context: context,
@ -471,8 +482,8 @@ class _SettingsState extends State<Settings> {
onPressed: () => LinkUtil.launch(
Constants.portfolioLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
FontAwesomeIcons.addressCard,
),
@ -487,8 +498,8 @@ class _SettingsState extends State<Settings> {
onPressed: () => LinkUtil.launch(
Constants.privacyPolicyLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.privacy_tip_outlined,
),
@ -501,8 +512,8 @@ class _SettingsState extends State<Settings> {
),
ElevatedButton(
onPressed: onReportIssueTapped,
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.bug_report_outlined,
),
@ -517,8 +528,8 @@ class _SettingsState extends State<Settings> {
onPressed: () => LinkUtil.launch(
Constants.githubLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
FontAwesomeIcons.github,
),
@ -535,8 +546,8 @@ class _SettingsState extends State<Settings> {
? Constants.appStoreLink
: Constants.googlePlayLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.thumb_up,
),
@ -551,8 +562,8 @@ class _SettingsState extends State<Settings> {
onPressed: () => LinkUtil.launch(
Constants.sponsorLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
FeatherIcons.coffee,
),
@ -576,8 +587,8 @@ class _SettingsState extends State<Settings> {
actions: <Widget>[
ElevatedButton(
onPressed: onSendEmailTapped,
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.email,
),
@ -590,8 +601,8 @@ class _SettingsState extends State<Settings> {
),
ElevatedButton(
onPressed: () => onGithubTapped(context.rect),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.bug_report_outlined,
),
@ -751,7 +762,7 @@ class _SettingsState extends State<Settings> {
try {
await FlutterClipboard.copy(
allFavorites.join('\n'),
).whenComplete(HapticFeedback.selectionClick);
).whenComplete(HapticFeedbackUtil.selection);
showSnackBar(content: 'Ids of favorites have been copied to clipboard.');
} catch (error, stackTrace) {
error.logError(stackTrace: stackTrace);

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class TabBarSettings extends StatefulWidget {
const TabBarSettings({super.key});
@ -20,8 +20,8 @@ class _TabBarSettingsState extends State<TabBarSettings> {
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Row(
children: const <Widget>[
const Row(
children: <Widget>[
SizedBox(
width: Dimens.pt16,
),
@ -39,7 +39,7 @@ class _TabBarSettingsState extends State<TabBarSettings> {
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
onReorder: context.read<TabCubit>().update,
onReorderStart: (_) => HapticFeedback.lightImpact(),
onReorderStart: (_) => HapticFeedbackUtil.light(),
children: <Widget>[
for (final StoryType tab in state.tabs)
InkWell(

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/cubits/cubits.dart';
@ -28,7 +29,39 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen> {
final RefreshController refreshController = RefreshController();
final ScrollController scrollController = ScrollController();
final Debouncer debouncer = Debouncer(delay: const Duration(seconds: 1));
bool showChips = true;
bool shouldOffStageChips = false;
static const Duration chipsAnimationDuration = Duration(milliseconds: 300);
@override
void initState() {
super.initState();
scrollController.addListener(() {
if (scrollController.position.userScrollDirection ==
ScrollDirection.reverse &&
showChips) {
setState(() {
showChips = false;
});
} else if (scrollController.position.userScrollDirection ==
ScrollDirection.forward &&
!showChips) {
setState(() {
showChips = true;
});
}
});
}
@override
void dispose() {
refreshController.dispose();
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@ -72,78 +105,164 @@ class _SearchScreenState extends State<SearchScreen> {
const SizedBox(
height: Dimens.pt6,
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
AnimatedCrossFade(
duration: chipsAnimationDuration,
crossFadeState: showChips
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: SizedBox.fromSize(),
secondChild: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeRangeFilterChip(
filter: state.params.get<DateTimeRangeFilter>(),
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
onDateTimeRangeRemoved: context
.read<SearchCubit>()
.removeFilter<DateTimeRangeFilter>,
),
const SizedBox(
width: Dimens.pt8,
),
PostedByFilterChip(
filter: state.params.get<PostedByFilter>(),
onChanged:
context.read<SearchCubit>().onPostedByChanged,
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onSortToggled(),
selected: state.params.sorted,
label: '''newest first''',
),
const SizedBox(
width: Dimens.pt8,
),
for (final CustomDateTimeRange range
in CustomDateTimeRange.values) ...<Widget>[
CustomRangeFilterChip(
range: range,
onTap: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
if (state.hasDateFilter &&
state.dateFilter?.startTime != null &&
state.dateFilter?.endTime != null)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter!.startTime!,
endDate: state.dateFilter!.endTime!,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter!.startTime!,
endDate: state.dateFilter!.endTime!,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter!.startTime!,
endDate: state.dateFilter!.endTime!,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter!.startTime!,
endDate: state.dateFilter!.endTime!,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter!.startTime!,
endDate: state.dateFilter!.endTime!,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter!.startTime!,
endDate: state.dateFilter!.endTime!,
),
],
),
),
const SizedBox(
width: Dimens.pt8,
),
],
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
for (final TypeTagFilter filter
in TypeTagFilter.all) ...<Widget>[
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onToggled(filter),
selected: context
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeRangeFilterChip(
filter: state.dateFilter,
initialStartDate: state.dateFilter?.startTime,
initialEndDate: state.dateFilter?.endTime,
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.state
.params
.get<TypeTagFilter>() ==
filter,
label: filter.query,
.onDateTimeRangeUpdated,
onDateTimeRangeRemoved: context
.read<SearchCubit>()
.removeFilter<DateTimeRangeFilter>,
),
const SizedBox(
width: Dimens.pt8,
),
PostedByFilterChip(
filter: state.params.get<PostedByFilter>(),
onChanged: context
.read<SearchCubit>()
.onPostedByChanged,
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onSortToggled(),
selected: state.params.sorted,
label: '''newest first''',
),
const SizedBox(
width: Dimens.pt8,
),
for (final CustomDateTimeRange range
in CustomDateTimeRange.values) ...<Widget>[
CustomRangeFilterChip(
range: range,
onTap: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
),
const SizedBox(
width: Dimens.pt8,
),
],
],
),
],
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
for (final TypeTagFilter filter
in TypeTagFilter.all) ...<Widget>[
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) => context
.read<SearchCubit>()
.onToggled(filter),
selected: context
.read<SearchCubit>()
.state
.params
.get<TypeTagFilter>() ==
filter,
label: filter.query,
),
],
],
),
),
],
),
),
@ -200,11 +319,15 @@ class _SearchScreenState extends State<SearchScreen> {
},
),
controller: refreshController,
scrollController: scrollController,
onRefresh: () {},
onLoading: () {
context.read<SearchCubit>().loadMore();
},
child: ListView(
physics: state.results.isEmpty
? const NeverScrollableScrollPhysics()
: null,
children: <Widget>[
...state.results
.map(

View File

@ -17,9 +17,9 @@ enum CustomDateTimeRange {
class CustomRangeFilterChip extends StatelessWidget {
const CustomRangeFilterChip({
super.key,
required this.range,
required this.onTap,
super.key,
});
final CustomDateTimeRange range;

View File

@ -5,13 +5,17 @@ import 'package:intl/intl.dart';
class DateTimeRangeFilterChip extends StatelessWidget {
const DateTimeRangeFilterChip({
super.key,
required this.filter,
required this.initialStartDate,
required this.initialEndDate,
required this.onDateTimeRangeUpdated,
required this.onDateTimeRangeRemoved,
super.key,
});
final DateTimeRangeFilter? filter;
final DateTime? initialStartDate;
final DateTime? initialEndDate;
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
final VoidCallback onDateTimeRangeRemoved;
@ -25,6 +29,9 @@ class DateTimeRangeFilterChip extends StatelessWidget {
context: context,
firstDate: DateTime.now().subtract(const Duration(days: 20 * 365)),
lastDate: DateTime.now(),
initialDateRange: initialStartDate != null && initialEndDate != null
? DateTimeRange(start: initialStartDate!, end: initialEndDate!)
: null,
).then((DateTimeRange? range) {
if (range != null) {
onDateTimeRangeUpdated(range.start, range.end);
@ -34,11 +41,22 @@ class DateTimeRangeFilterChip extends StatelessWidget {
});
},
selected: filter != null,
label:
'''from ${_formatDateTime(filter?.startTime) ?? 'X'} to ${_formatDateTime(filter?.endTime) ?? 'Y'}''',
label: _label,
);
}
String get _label {
final DateTime? start = filter?.startTime;
final DateTime? end = filter?.endTime;
if (start == null && end == null) {
return '''from X to Y''';
} else if (start == end) {
return '''from ${_formatDateTime(start)}''';
} else {
return '''from ${_formatDateTime(start)} to ${_formatDateTime(end)}''';
}
}
static String? _formatDateTime(DateTime? dateTime) {
if (dateTime == null) return null;

View File

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:hacki/screens/search/widgets/date_time_range_filter_chip.dart';
import 'package:hacki/screens/widgets/widgets.dart' show CustomChip;
typedef Calculator = DateTime Function(DateTime);
/// A set of chips that perform addition or subtraction on the date selected
/// by [DateTimeRangeFilterChip]
class DateTimeShortcutChip extends StatelessWidget {
const DateTimeShortcutChip({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
required this.label,
required Calculator calculator,
super.key,
}) : _calculator = calculator;
DateTimeShortcutChip.dayBefore({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '- day',
_calculator =
((DateTime date) => date.subtract(const Duration(hours: 24)));
DateTimeShortcutChip.dayAfter({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '+ day',
_calculator = ((DateTime date) => date.add(const Duration(hours: 24)));
DateTimeShortcutChip.weekBefore({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '- week',
_calculator =
((DateTime date) => date.subtract(const Duration(days: 7)));
DateTimeShortcutChip.weekAfter({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '+ week',
_calculator = ((DateTime date) => date.add(const Duration(days: 7)));
DateTimeShortcutChip.monthBefore({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '- 30 days',
_calculator =
((DateTime date) => date.subtract(const Duration(days: 30)));
DateTimeShortcutChip.monthAfter({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '+ 30 days',
_calculator = ((DateTime date) => date.add(const Duration(days: 30)));
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
final DateTime startDate;
final DateTime endDate;
final String label;
final Calculator _calculator;
@override
Widget build(BuildContext context) {
return CustomChip(
onSelected: (bool value) {
final DateTime updatedStartDate = _calculator(startDate);
final DateTime updatedEndDate = _calculator(endDate);
onDateTimeRangeUpdated(updatedStartDate, updatedEndDate);
},
selected: false,
label: label,
);
}
}

View File

@ -5,9 +5,9 @@ import 'package:hacki/styles/styles.dart';
class PostedByFilterChip extends StatelessWidget {
const PostedByFilterChip({
super.key,
required this.filter,
required this.onChanged,
super.key,
});
final PostedByFilter? filter;

View File

@ -1,3 +1,4 @@
export 'custom_range_filter_chip.dart';
export 'date_time_range_filter_chip.dart';
export 'date_time_shortcut_chip.dart';
export 'posted_by_filter_chip.dart';

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class SubmitScreen extends StatefulWidget {
const SubmitScreen({super.key});
@ -45,7 +45,7 @@ class _SubmitScreenState extends State<SubmitScreen> {
listener: (BuildContext context, SubmitState state) {
if (state.status == SubmitStatus.submitted) {
Navigator.pop(context);
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
showSnackBar(
content: 'Post submitted successfully.',
);

View File

@ -6,8 +6,8 @@ import 'package:webview_flutter/webview_flutter.dart';
class WebViewScreen extends StatefulWidget {
const WebViewScreen({
super.key,
required this.url,
super.key,
});
final String url;

View File

@ -17,8 +17,8 @@ class BlocBuilder3<
BlocC extends StateStreamable<BlocCState>,
BlocCState> extends StatelessWidget {
const BlocBuilder3({
super.key,
required this.builder,
super.key,
this.blocA,
this.blocB,
this.blocC,

View File

@ -3,11 +3,17 @@ import 'package:hacki/styles/styles.dart';
class CenteredText extends StatelessWidget {
const CenteredText({
super.key,
required this.text,
super.key,
this.color = Palette.grey,
});
const CenteredText.hidden({Key? key})
: this(
key: key,
text: 'hidden',
);
const CenteredText.deleted({Key? key})
: this(
key: key,

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
@ -9,12 +8,13 @@ import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class CommentTile extends StatelessWidget {
const CommentTile({
super.key,
required this.comment,
required this.fetchMode,
super.key,
this.onReplyTapped,
this.onMoreTapped,
this.onEditTapped,
@ -43,13 +43,11 @@ class CommentTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (comment.hidden) return const SizedBox.shrink();
return BlocProvider<CollapseCubit>(
key: ValueKey<String>('${comment.id}-BlocProvider'),
lazy: false,
create: (_) => CollapseCubit(
commentId: comment.id,
commentsCubit: context.tryRead<CommentsCubit>(),
collapseCache: context.tryRead<CollapseCache>() ?? CollapseCache(),
)..init(),
child: BlocBuilder3<CollapseCubit, CollapseState, PreferenceCubit,
@ -65,8 +63,7 @@ class CommentTile extends StatelessWidget {
const Color orange = Color.fromRGBO(255, 152, 0, 1);
final Color color = _getColor(level);
final Padding child = Padding(
padding: EdgeInsets.zero,
final Widget child = DeviceGestureWrapper(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
@ -119,7 +116,7 @@ class CommentTile extends StatelessWidget {
child: InkWell(
onTap: () {
if (actionable) {
HapticFeedback.selectionClick();
HapticFeedbackUtil.selection();
context.read<CollapseCubit>().collapse();
} else {
onTap?.call();
@ -172,6 +169,8 @@ class CommentTile extends StatelessWidget {
'''collapsed (${state.collapsedCount + 1})''',
color: Palette.orangeAccent,
)
else if (comment.hidden)
const CenteredText.hidden()
else if (comment.deleted)
const CenteredText.deleted()
else if (comment.dead)
@ -183,7 +182,7 @@ class CommentTile extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt8,
right: Dimens.pt8,
right: Dimens.pt2,
top: Dimens.pt6,
bottom: Dimens.pt12,
),
@ -220,7 +219,7 @@ class CommentTile extends StatelessWidget {
Expanded(
child: TextButton(
onPressed: () {
HapticFeedback.selectionClick();
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().loadMore(
comment: comment,
);
@ -340,7 +339,7 @@ class CommentTile extends StatelessWidget {
void _onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick();
HapticFeedbackUtil.selection();
context.read<CollapseCubit>().collapse();
}
}

View File

@ -125,14 +125,14 @@ class _CountDownReminderState extends State<CountdownReminder>
},
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
const Padding(
padding: EdgeInsets.only(
left: Dimens.pt12,
top: Dimens.pt10,
right: Dimens.pt10,
),
child: Row(
children: const <Widget>[
children: <Widget>[
Text(
'Pick up where you left off',
style: TextStyle(

View File

@ -3,10 +3,10 @@ import 'package:hacki/styles/styles.dart';
class CustomChip extends StatelessWidget {
CustomChip({
Key? key,
required this.selected,
required this.label,
required this.onSelected,
Key? key,
}) : super(key: key ?? Key(label));
final bool selected;

View File

@ -2,16 +2,17 @@ import 'dart:async';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hacki/utils/utils.dart';
class CustomDescribedFeatureOverlay extends StatelessWidget {
const CustomDescribedFeatureOverlay({
super.key,
required this.featureId,
required this.child,
required this.tapTarget,
required this.title,
required this.description,
super.key,
this.contentLocation = ContentLocation.trivial,
this.onComplete,
});
@ -20,6 +21,7 @@ class CustomDescribedFeatureOverlay extends StatelessWidget {
final Widget title;
final Widget description;
final Widget child;
final ContentLocation contentLocation;
final VoidCallback? onComplete;
@override
@ -32,14 +34,15 @@ class CustomDescribedFeatureOverlay extends StatelessWidget {
title: title,
description: description,
barrierDismissible: false,
contentLocation: contentLocation,
onBackgroundTap: () {
unawaited(HapticFeedback.lightImpact());
HapticFeedbackUtil.light();
FeatureDiscovery.completeCurrentStep(context);
onComplete?.call();
return Future<bool>.value(true);
},
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
HapticFeedbackUtil.light();
onComplete?.call();
return true;
},

View File

@ -8,15 +8,15 @@ import 'package:linkify/linkify.dart';
export 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
export 'package:linkify/linkify.dart'
show
EmailElement,
EmailLinkifier,
LinkableElement,
Linkifier,
LinkifyElement,
LinkifyOptions,
LinkableElement,
TextElement,
Linkifier,
UrlElement,
UrlLinkifier,
EmailElement,
EmailLinkifier;
UrlLinkifier;
/// Callback clicked link
typedef LinkCallback = void Function(LinkableElement link);
@ -24,8 +24,8 @@ typedef LinkCallback = void Function(LinkableElement link);
/// Turns URLs into links
class Linkify extends StatelessWidget {
const Linkify({
super.key,
required this.text,
super.key,
this.linkifiers = defaultLinkifiers,
this.onOpen,
this.options = LinkifierUtil.linkifyOptions,
@ -151,8 +151,8 @@ const List<Linkifier> defaultLinkifiers = <Linkifier>[
/// Turns URLs into links
class SelectableLinkify extends StatelessWidget {
const SelectableLinkify({
super.key,
required this.text,
super.key,
this.semanticsLabel,
this.linkifiers = defaultLinkifiers,
this.onOpen,

View File

@ -1,17 +1,17 @@
import 'package:badges/badges.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/haptic_feedback_util.dart';
class CustomTabBar extends StatefulWidget {
const CustomTabBar({
super.key,
required this.tabController,
super.key,
});
final TabController tabController;
@ -52,7 +52,7 @@ class _CustomTabBarState extends State<CustomTabBar> {
bottom: Dimens.pt8,
),
onTap: (_) {
HapticFeedback.selectionClick();
HapticFeedbackUtil.selection();
},
tabs: <Widget>[
for (int i = 0; i < state.tabs.length; i++)

View File

@ -0,0 +1,25 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// A walk-around for [SelectableText] not responding to swipe gestures
/// while wrapped in a [Dismissible].
///
/// https://github.com/flutter/flutter/issues/124421#issuecomment-1500666795
class DeviceGestureWrapper extends StatelessWidget {
const DeviceGestureWrapper({
required this.child,
super.key,
});
final Widget child;
@override
Widget build(BuildContext context) {
return MediaQuery(
data: const MediaQueryData(
gestureSettings: DeviceGestureSettings(touchSlop: 7.9),
),
child: child,
);
}
}

View File

@ -9,8 +9,8 @@ import 'package:hacki/utils/utils.dart';
class ItemText extends StatelessWidget {
const ItemText({
super.key,
required this.item,
super.key,
this.onTap,
});

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@ -14,13 +13,13 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class ItemsListView<T extends Item> extends StatelessWidget {
const ItemsListView({
super.key,
required this.showWebPreview,
required this.showMetadata,
required this.showUrl,
required this.items,
required this.onTap,
required this.refreshController,
super.key,
this.useCommentTile = false,
this.showCommentBy = false,
this.enablePullDown = true,
@ -94,7 +93,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
HapticFeedbackUtil.light();
onPinned?.call(e);
},
backgroundColor: Palette.orange,

View File

@ -7,17 +7,17 @@ import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/link_view.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkPreview extends StatefulWidget {
const LinkPreview({
super.key,
required this.link,
required this.story,
required this.onTap,
required this.showMetadata,
required this.showUrl,
required this.isOfflineReading,
required this.titleStyle,
super.key,
this.cache = const Duration(days: 30),
this.showMultimedia = true,
this.backgroundColor = const Color.fromRGBO(235, 235, 235, 1),
@ -34,6 +34,7 @@ class LinkPreview extends StatefulWidget {
});
final Story story;
final VoidCallback onTap;
/// Web address (Url that need to be parsed)
/// For IOS & Web, only HTTP and HTTPS are support
@ -141,19 +142,6 @@ class _LinkPreviewState extends State<LinkPreview> {
}
}
Future<void> _launchURL(String url) async {
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
try {
await launchUrl(uri);
} catch (err) {
throw Exception('Could not launch $url. Error: $err');
}
}
}
Widget _buildLinkContainer(
double height, {
String? title = '',
@ -184,7 +172,7 @@ class _LinkPreviewState extends State<LinkPreview> {
description: desc ?? title ?? 'no comment yet.',
imageUri: imageUri,
imagePath: Constants.hackerNewsLogoPath,
onTap: _launchURL,
onTap: widget.onTap,
titleTextStyle: widget.titleStyle,
bodyTextOverflow: widget.bodyTextOverflow,
bodyMaxLines: widget.bodyMaxLines,

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