mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
c7d1a42d5a | |||
f83fd66bcc | |||
c2ec3647e2 | |||
ba63852b7d | |||
438041183c | |||
114540edd7 | |||
588b3e9508 | |||
2f0376f8f8 | |||
ab4051c018 | |||
c230c21218 | |||
c24e12237e |
BIN
assets/fonts/noto_serif/NotoSerif-Bold.ttf
Normal file
BIN
assets/fonts/noto_serif/NotoSerif-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/noto_serif/NotoSerif-Regular.ttf
Normal file
BIN
assets/fonts/noto_serif/NotoSerif-Regular.ttf
Normal file
Binary file not shown.
30
components/in_app_review/.gitignore
vendored
Normal file
30
components/in_app_review/.gitignore
vendored
Normal 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/
|
39
components/in_app_review/.metadata
Normal file
39
components/in_app_review/.metadata
Normal 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'
|
102
components/in_app_review/CHANGELOG.md
Normal file
102
components/in_app_review/CHANGELOG.md
Normal 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
|
21
components/in_app_review/LICENSE
Normal file
21
components/in_app_review/LICENSE
Normal 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.
|
0
components/in_app_review/analysis_options.yaml
Normal file
0
components/in_app_review/analysis_options.yaml
Normal file
9
components/in_app_review/android/.gitignore
vendored
Normal file
9
components/in_app_review/android/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
/.idea/libraries
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.cxx
|
49
components/in_app_review/android/build.gradle
Normal file
49
components/in_app_review/android/build.gradle
Normal 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 {
|
||||
|
||||
}
|
||||
}
|
1
components/in_app_review/android/settings.gradle
Normal file
1
components/in_app_review/android/settings.gradle
Normal file
@ -0,0 +1 @@
|
||||
rootProject.name = 'in_app_review'
|
@ -0,0 +1,3 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="dev.britannio.in_app_review">
|
||||
</manifest>
|
@ -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
38
components/in_app_review/ios/.gitignore
vendored
Normal 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
|
0
components/in_app_review/ios/Assets/.gitkeep
Normal file
0
components/in_app_review/ios/Assets/.gitkeep
Normal file
4
components/in_app_review/ios/Classes/InAppReviewPlugin.h
Normal file
4
components/in_app_review/ios/Classes/InAppReviewPlugin.h
Normal file
@ -0,0 +1,4 @@
|
||||
#import <Flutter/Flutter.h>
|
||||
|
||||
@interface InAppReviewPlugin : NSObject<FlutterPlugin>
|
||||
@end
|
107
components/in_app_review/ios/Classes/InAppReviewPlugin.m
Normal file
107
components/in_app_review/ios/Classes/InAppReviewPlugin.m
Normal 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
|
23
components/in_app_review/ios/in_app_review.podspec
Normal file
23
components/in_app_review/ios/in_app_review.podspec
Normal 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
|
50
components/in_app_review/lib/in_app_review.dart
Normal file
50
components/in_app_review/lib/in_app_review.dart
Normal 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,
|
||||
);
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
22
components/in_app_review/macos/in_app_review.podspec
Normal file
22
components/in_app_review/macos/in_app_review.podspec
Normal 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
|
46
components/in_app_review/pubspec.yaml
Normal file
46
components/in_app_review/pubspec.yaml
Normal 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
|
108
components/in_app_review/test/in_app_review_test.dart
Normal file
108
components/in_app_review/test/in_app_review_test.dart
Normal file
@ -0,0 +1,108 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:in_app_review_platform_interface/in_app_review_platform_interface.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final inAppReview = InAppReview.instance;
|
||||
late MockInAppReviewPlatform platform;
|
||||
|
||||
setUp(() {
|
||||
platform = MockInAppReviewPlatform();
|
||||
InAppReviewPlatform.instance = platform;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
verifyNoMoreInteractions(platform);
|
||||
});
|
||||
|
||||
group('isAvailable', () {
|
||||
test(
|
||||
'should call InAppReviewPlatform.isAvailable()',
|
||||
() async {
|
||||
// ARRANGE
|
||||
when(platform.isAvailable()).thenAnswer((_) async => true);
|
||||
|
||||
// ACT
|
||||
final result = await inAppReview.isAvailable();
|
||||
|
||||
// ASSERT
|
||||
verify(platform.isAvailable());
|
||||
expect(result, isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
group('requestReview', () {
|
||||
test(
|
||||
'should call InAppReviewPlatform.requestReview()',
|
||||
() async {
|
||||
// ARRANGE
|
||||
when(platform.requestReview()).thenAnswer((_) async {});
|
||||
|
||||
// ACT
|
||||
await inAppReview.requestReview();
|
||||
|
||||
// ASSERT
|
||||
verify(platform.requestReview());
|
||||
},
|
||||
);
|
||||
});
|
||||
group('openStoreListing', () {
|
||||
test(
|
||||
'should call InAppReviewPlatform.openStoreListing()',
|
||||
() async {
|
||||
// ARRANGE
|
||||
const appStoreId = 'app_store_id';
|
||||
const microsoftStoreId = 'microsoft_store_id';
|
||||
when(platform.openStoreListing(
|
||||
appStoreId: appStoreId,
|
||||
microsoftStoreId: microsoftStoreId,
|
||||
)).thenAnswer((_) async {});
|
||||
|
||||
// ACT
|
||||
await inAppReview.openStoreListing(
|
||||
appStoreId: appStoreId,
|
||||
microsoftStoreId: microsoftStoreId,
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
verify(platform.openStoreListing(
|
||||
appStoreId: appStoreId,
|
||||
microsoftStoreId: microsoftStoreId,
|
||||
));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class MockInAppReviewPlatform extends Mock
|
||||
with MockPlatformInterfaceMixin
|
||||
implements InAppReviewPlatform {
|
||||
@override
|
||||
Future<bool> isAvailable() => super.noSuchMethod(
|
||||
Invocation.method(#isAvailable, null),
|
||||
returnValue: Future.value(true),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> requestReview() => super.noSuchMethod(
|
||||
Invocation.method(#requestReview, null),
|
||||
returnValue: Future<void>.value(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> openStoreListing({
|
||||
String? appStoreId,
|
||||
String? microsoftStoreId,
|
||||
}) =>
|
||||
super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#openStoreListing,
|
||||
null,
|
||||
{#appStoreId: appStoreId, #microsoftStoreId: microsoftStoreId},
|
||||
),
|
||||
returnValue: Future<void>.value(),
|
||||
);
|
||||
}
|
12
components/in_app_review_platform_interface/.gitignore
vendored
Normal file
12
components/in_app_review_platform_interface/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
.DS_Store
|
||||
.dart_tool/
|
||||
|
||||
.packages
|
||||
.pub/
|
||||
|
||||
build/
|
||||
|
||||
|
||||
pubspec.lock
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
46
components/in_app_review_platform_interface/CHANGELOG.md
Normal file
46
components/in_app_review_platform_interface/CHANGELOG.md
Normal 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.
|
21
components/in_app_review_platform_interface/LICENSE
Normal file
21
components/in_app_review_platform_interface/LICENSE
Normal 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.
|
26
components/in_app_review_platform_interface/README.md
Normal file
26
components/in_app_review_platform_interface/README.md
Normal 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
|
@ -0,0 +1,71 @@
|
||||
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();
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
24
components/in_app_review_platform_interface/pubspec.yaml
Normal file
24
components/in_app_review_platform_interface/pubspec.yaml
Normal 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
|
||||
|
@ -0,0 +1,136 @@
|
||||
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();
|
||||
});
|
||||
|
||||
channel.setMockMethodCallHandler((MethodCall call) async {
|
||||
log.add(call);
|
||||
|
||||
switch (call.method) {
|
||||
case 'isAvailable':
|
||||
return true;
|
||||
case 'requestReview':
|
||||
case 'openStoreListing':
|
||||
return null;
|
||||
default:
|
||||
assert(false);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
}
|
1
fastlane/metadata/android/en-US/changelogs/108.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/108.txt
Normal file
@ -0,0 +1 @@
|
||||
- Navigation shortcuts.
|
@ -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)
|
||||
@ -62,6 +64,7 @@ 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`)
|
||||
@ -98,6 +101,8 @@ 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:
|
||||
@ -133,6 +138,7 @@ SPEC CHECKSUMS:
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
|
||||
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
|
@ -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>[
|
||||
'(๑•̀ㅂ•́)و✧',
|
||||
|
@ -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>>(),
|
||||
);
|
||||
|
@ -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(
|
||||
|
@ -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';
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -183,7 +182,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 +209,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
}
|
||||
|
||||
void loadAll(Story story) {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedbackUtil.light();
|
||||
emit(
|
||||
state.copyWith(
|
||||
onlyShowTargetComment: false,
|
||||
@ -221,7 +220,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) {
|
||||
@ -269,18 +272,19 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
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 +302,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 +342,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 +352,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 +463,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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -30,7 +30,6 @@ extension ContextExtension on BuildContext {
|
||||
textColor: Theme.of(this).textTheme.bodyLarge?.color,
|
||||
)
|
||||
: null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -18,9 +18,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 +123,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 +139,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;
|
||||
|
||||
@ -276,6 +269,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) =>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,6 +6,7 @@ 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({
|
||||
@ -32,7 +32,7 @@ class PinnedStories extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
SlidableAction(
|
||||
onPressed: (_) {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedbackUtil.light();
|
||||
context.read<PinCubit>().unpinStory(story);
|
||||
},
|
||||
backgroundColor: Palette.red,
|
||||
|
@ -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({
|
||||
@ -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(
|
||||
|
@ -1,14 +1,13 @@
|
||||
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 VoidCallback onFontSizeTap,
|
||||
@ -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(
|
||||
|
99
lib/screens/item/widgets/custom_floating_action_button.dart
Normal file
99
lib/screens/item/widgets/custom_floating_action_button.dart
Normal 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({
|
||||
super.key,
|
||||
required this.itemScrollController,
|
||||
required this.itemPositionsListener,
|
||||
});
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
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({
|
||||
@ -38,7 +38,7 @@ class FavIconButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedbackUtil.light();
|
||||
if (isFav) {
|
||||
context.read<FavCubit>().removeFav(storyId);
|
||||
} else {
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
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,9 +205,6 @@ 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),
|
||||
@ -250,14 +216,15 @@ class _ParentItemSection extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
SlidableAction(
|
||||
onPressed: (_) {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedbackUtil.light();
|
||||
|
||||
if (state.item.id !=
|
||||
context.read<EditCubit>().state.replyingTo?.id) {
|
||||
commentEditingController.clear();
|
||||
}
|
||||
context.read<EditCubit>().onReplyTapped(state.item);
|
||||
focusNode.requestFocus();
|
||||
|
||||
onReplyTapped();
|
||||
},
|
||||
backgroundColor: Palette.orange,
|
||||
foregroundColor: Palette.white,
|
||||
@ -435,22 +402,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(),
|
||||
|
@ -2,12 +2,14 @@ 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';
|
||||
|
||||
@ -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',
|
||||
),
|
||||
|
@ -1,13 +1,13 @@
|
||||
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({
|
||||
@ -49,7 +49,7 @@ class PinIconButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedbackUtil.light();
|
||||
if (pinned) {
|
||||
context.read<PinCubit>().unpinStory(story);
|
||||
} else {
|
||||
|
@ -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(
|
||||
|
@ -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,12 +8,12 @@ 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,
|
||||
@ -22,7 +21,6 @@ class ReplyBox extends StatefulWidget {
|
||||
});
|
||||
|
||||
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 '
|
||||
'${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(
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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';
|
||||
@ -102,7 +101,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 +165,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
refreshController: refreshControllerFav,
|
||||
items: favState.favItems,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedbackUtil.light();
|
||||
context.read<FavCubit>().refresh();
|
||||
},
|
||||
onLoadMore: () {
|
||||
@ -221,7 +220,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
context.read<NotificationCubit>().loadMore();
|
||||
},
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedbackUtil.light();
|
||||
context.read<NotificationCubit>().refresh();
|
||||
},
|
||||
),
|
||||
|
@ -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';
|
||||
@ -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,7 +334,7 @@ class _SettingsState extends State<Settings> {
|
||||
}
|
||||
},
|
||||
title: Text(
|
||||
font.label,
|
||||
font.uiLabel,
|
||||
style: TextStyle(fontFamily: font.name),
|
||||
),
|
||||
),
|
||||
@ -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,24 @@ class _SettingsState extends State<Settings> {
|
||||
);
|
||||
}
|
||||
|
||||
void updateThemeSetting(AdaptiveThemeMode? val) {
|
||||
switch (val) {
|
||||
case AdaptiveThemeMode.light:
|
||||
AdaptiveTheme.of(context).setLight();
|
||||
break;
|
||||
case AdaptiveThemeMode.dark:
|
||||
AdaptiveTheme.of(context).setDark();
|
||||
break;
|
||||
case AdaptiveThemeMode.system:
|
||||
case null:
|
||||
AdaptiveTheme.of(context).setSystem();
|
||||
break;
|
||||
}
|
||||
|
||||
final Brightness brightness = Theme.of(context).brightness;
|
||||
ThemeUtil.updateAndroidStatusBarSetting(brightness, val);
|
||||
}
|
||||
|
||||
void showClearCacheDialog() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
@ -751,7 +765,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);
|
||||
|
@ -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});
|
||||
@ -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(
|
||||
|
@ -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.',
|
||||
);
|
||||
|
@ -8,6 +8,12 @@ class CenteredText extends StatelessWidget {
|
||||
this.color = Palette.grey,
|
||||
});
|
||||
|
||||
const CenteredText.hidden({Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
text: 'hidden',
|
||||
);
|
||||
|
||||
const CenteredText.deleted({Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
|
@ -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,6 +8,7 @@ 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({
|
||||
@ -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,
|
||||
@ -119,7 +117,7 @@ class CommentTile extends StatelessWidget {
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (actionable) {
|
||||
HapticFeedback.selectionClick();
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
} else {
|
||||
onTap?.call();
|
||||
@ -172,6 +170,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 +183,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 +220,7 @@ class CommentTile extends StatelessWidget {
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CommentsCubit>().loadMore(
|
||||
comment: comment,
|
||||
);
|
||||
@ -340,7 +340,7 @@ class CommentTile extends StatelessWidget {
|
||||
|
||||
void _onTextTapped(BuildContext context) {
|
||||
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
|
||||
HapticFeedback.selectionClick();
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ 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({
|
||||
@ -12,6 +12,7 @@ class CustomDescribedFeatureOverlay extends StatelessWidget {
|
||||
required this.tapTarget,
|
||||
required this.title,
|
||||
required this.description,
|
||||
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;
|
||||
},
|
||||
|
@ -1,12 +1,12 @@
|
||||
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({
|
||||
@ -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++)
|
||||
|
@ -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';
|
||||
@ -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,
|
||||
|
@ -2,6 +2,8 @@ import 'dart:math';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/link_preview/models/models.dart';
|
||||
@ -141,6 +143,8 @@ class LinkView extends StatelessWidget {
|
||||
return maxLines;
|
||||
}
|
||||
|
||||
static bool? isUsingSerifFont;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
@ -151,7 +155,6 @@ class LinkView extends StatelessWidget {
|
||||
final String? fontFamily =
|
||||
Theme.of(context).primaryTextTheme.bodyMedium?.fontFamily;
|
||||
final double textScaleFactor = MediaQuery.of(context).textScaleFactor;
|
||||
|
||||
final TextStyle titleStyle = titleTextStyle;
|
||||
final double titleHeight = (TextPainter(
|
||||
text: TextSpan(
|
||||
@ -164,7 +167,6 @@ class LinkView extends StatelessWidget {
|
||||
)..layout(maxWidth: bodyWidth))
|
||||
.size
|
||||
.height;
|
||||
|
||||
final int descriptionMaxLines = getDescriptionMaxLines(
|
||||
MaxLineComputationParams(
|
||||
fontFamily ?? Font.roboto.name,
|
||||
@ -178,6 +180,8 @@ class LinkView extends StatelessWidget {
|
||||
titleStyle,
|
||||
);
|
||||
|
||||
isUsingSerifFont ??= Font.fromString(fontFamily).isSerif;
|
||||
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
if (showMultiMedia)
|
||||
@ -193,6 +197,8 @@ class LinkView extends StatelessWidget {
|
||||
LinkUtil.launch(
|
||||
url,
|
||||
useHackiForHnLink: false,
|
||||
offlineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
);
|
||||
} else {
|
||||
onTap();
|
||||
@ -231,11 +237,7 @@ class LinkView extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
height:
|
||||
Theme.of(context).textTheme.bodyMedium?.fontFamily ==
|
||||
Font.robotoSlab.name
|
||||
? Dimens.pt2
|
||||
: Dimens.pt4,
|
||||
height: isUsingSerifFont! ? Dimens.pt2 : Dimens.pt4,
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
|
@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
@ -75,7 +74,7 @@ class _OnboardingViewState extends State<OnboardingView> {
|
||||
right: Dimens.zero,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedbackUtil.light();
|
||||
if (pageController.page! >= 2) {
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
|
@ -1,11 +1,11 @@
|
||||
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/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
class StoriesListView extends StatefulWidget {
|
||||
@ -73,7 +73,7 @@ class _StoriesListViewState extends State<StoriesListView> {
|
||||
refreshController: refreshController,
|
||||
items: state.storiesByType[storyType]!,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedbackUtil.light();
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesRefresh(type: storyType));
|
||||
|
52
lib/services/app_review_service.dart
Normal file
52
lib/services/app_review_service.dart
Normal file
@ -0,0 +1,52 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
|
||||
class AppReviewService {
|
||||
AppReviewService({PreferenceRepository? preferenceRepository})
|
||||
: _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>();
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
|
||||
static const String _lastRequestTimestampKey = 'lastRequestTimestamp';
|
||||
static const int _differenceInDays = 3;
|
||||
|
||||
void requestReview() {
|
||||
if (Platform.isIOS) {
|
||||
_shouldDisplay().then((bool val) {
|
||||
if (val) InAppReview.instance.requestReview();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _shouldDisplay() async {
|
||||
final DateTime now = DateTime.now();
|
||||
final int? timestamp =
|
||||
await _preferenceRepository.getInt(_lastRequestTimestampKey);
|
||||
|
||||
if (timestamp == null) {
|
||||
_preferenceRepository.setInt(
|
||||
_lastRequestTimestampKey,
|
||||
now.millisecondsSinceEpoch,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
final DateTime lastReviewRequest =
|
||||
DateTime.fromMillisecondsSinceEpoch(timestamp, isUtc: true);
|
||||
final int difference = now.difference(lastReviewRequest).inDays;
|
||||
|
||||
if (difference >= _differenceInDays) {
|
||||
_preferenceRepository.setInt(
|
||||
_lastRequestTimestampKey,
|
||||
now.millisecondsSinceEpoch,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
|
||||
class LocalNotification {
|
||||
class LocalNotificationService {
|
||||
Future<void> pushForNewReply(Comment newReply, int storyId) async {
|
||||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
@ -1,6 +1,7 @@
|
||||
export 'app_review_service.dart';
|
||||
export 'caches/caches.dart';
|
||||
export 'custom_bloc_observer.dart';
|
||||
export 'fetcher.dart';
|
||||
export 'firebase_client.dart';
|
||||
export 'local_notification.dart';
|
||||
export 'local_notification_service.dart';
|
||||
export 'web_analyzer.dart';
|
||||
|
7
lib/utils/haptic_feedback_util.dart
Normal file
7
lib/utils/haptic_feedback_util.dart
Normal file
@ -0,0 +1,7 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class HapticFeedbackUtil {
|
||||
static void selection() => HapticFeedback.selectionClick();
|
||||
|
||||
static void light() => HapticFeedback.lightImpact();
|
||||
}
|
66
lib/utils/theme_util.dart
Normal file
66
lib/utils/theme_util.dart
Normal file
@ -0,0 +1,66 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
abstract class ThemeUtil {
|
||||
/// Temp fix for the issue:
|
||||
/// https://github.com/flutter/flutter/issues/119465
|
||||
static Future<void> updateAndroidStatusBarSetting(
|
||||
Brightness brightness,
|
||||
AdaptiveThemeMode? mode,
|
||||
) async {
|
||||
if (Platform.isAndroid == false) return;
|
||||
|
||||
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
|
||||
final int sdk = androidInfo.version.sdkInt;
|
||||
|
||||
if (sdk > 28) return;
|
||||
switch (mode) {
|
||||
case AdaptiveThemeMode.light:
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarBrightness: Brightness.dark,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarColor: Palette.transparent,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case AdaptiveThemeMode.dark:
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarBrightness: Brightness.light,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarColor: Palette.transparent,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case AdaptiveThemeMode.system:
|
||||
case null:
|
||||
switch (brightness) {
|
||||
case Brightness.light:
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarBrightness: Brightness.dark,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarColor: Palette.transparent,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case Brightness.dark:
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarBrightness: Brightness.light,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarColor: Palette.transparent,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
export 'debouncer.dart';
|
||||
export 'haptic_feedback_util.dart';
|
||||
export 'html_util.dart';
|
||||
export 'link_util.dart';
|
||||
export 'linkifier_util.dart';
|
||||
export 'log_util.dart';
|
||||
export 'service_exception.dart';
|
||||
export 'theme_util.dart';
|
||||
export 'throttle.dart';
|
||||
|
25
pubspec.lock
25
pubspec.lock
@ -547,6 +547,21 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.0"
|
||||
in_app_review:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "components/in_app_review"
|
||||
relative: true
|
||||
source: path
|
||||
version: "2.0.6"
|
||||
in_app_review_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: in_app_review_platform_interface
|
||||
sha256: b12ec9aaf6b34d3a72aa95895eb252b381896246bdad4ef378d444affe8410ef
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@ -857,6 +872,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.27.7"
|
||||
scrollable_positioned_list:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: scrollable_positioned_list
|
||||
sha256: ca7fcaa743db712d4f7b1580526f494d0093c77a721a65705ee51fbeac7a2bd3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5"
|
||||
sembast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1375,4 +1398,4 @@ packages:
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.19.0 <3.0.0"
|
||||
flutter: ">=3.7.10"
|
||||
flutter: ">=3.7.11"
|
||||
|
12
pubspec.yaml
12
pubspec.yaml
@ -1,11 +1,11 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 1.4.2+106
|
||||
version: 1.5.1+109
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
flutter: "3.7.10"
|
||||
flutter: "3.7.11"
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^3.2.0
|
||||
@ -43,6 +43,8 @@ dependencies:
|
||||
html_unescape: ^2.0.0
|
||||
http: ^0.13.5
|
||||
hydrated_bloc: ^9.1.0
|
||||
in_app_review:
|
||||
path: components/in_app_review
|
||||
intl: ^0.18.0
|
||||
linkify: ^4.1.0
|
||||
logger: ^1.3.0
|
||||
@ -59,6 +61,7 @@ dependencies:
|
||||
receive_sharing_intent: ^1.4.5
|
||||
responsive_builder: ^0.5.1
|
||||
rxdart: ^0.27.7
|
||||
scrollable_positioned_list: ^0.3.5
|
||||
sembast: ^3.4.0+6
|
||||
share_plus: ^6.3.1
|
||||
shared_preferences: ^2.0.17
|
||||
@ -108,5 +111,10 @@ flutter:
|
||||
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
|
||||
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
|
||||
weight: 700
|
||||
- family: NotoSerif
|
||||
fonts:
|
||||
- asset: assets/fonts/noto_serif/NotoSerif-Regular.ttf
|
||||
- asset: assets/fonts/noto_serif/NotoSerif-Bold.ttf
|
||||
weight: 700
|
||||
|
||||
|
||||
|
Submodule submodules/flutter updated: 4b12645012...f72efea43c
Reference in New Issue
Block a user