Compare commits
103 Commits
Author | SHA1 | Date | |
---|---|---|---|
ab4051c018 | |||
c230c21218 | |||
c24e12237e | |||
e15dcba93b | |||
1362b93a74 | |||
ac18793f98 | |||
e52f65c773 | |||
06212a0d72 | |||
e77c0e3e73 | |||
cb6f41ec49 | |||
ab1e90ccad | |||
0ca3e96d91 | |||
d1c8eed3de | |||
aa6a2c684c | |||
d4778d9530 | |||
c702e08481 | |||
2af10391bc | |||
c420dd3ca4 | |||
da7d0757cd | |||
32ae2087bc | |||
0b5329d050 | |||
c375def289 | |||
3469543c7b | |||
ab755581fd | |||
6b75eb8549 | |||
36ded8a8e3 | |||
582ac7b0be | |||
e5e3391785 | |||
9159fe0fe1 | |||
7c51bad35e | |||
6836138d11 | |||
2f71964277 | |||
c24c5c1b7a | |||
755b112382 | |||
d44b64d249 | |||
35ed917e66 | |||
15b75ef37c | |||
f39408fbcc | |||
ca2f063297 | |||
1ad231adbb | |||
60b09fd81e | |||
fe162208ca | |||
58139ba7a3 | |||
33a31acbe2 | |||
0fcfcbb7e3 | |||
a98f52c90b | |||
8e8e48c44a | |||
603b7cc939 | |||
649fa33df3 | |||
81d4a0f2df | |||
24112a471e | |||
c7824eaef3 | |||
c2b66d29c3 | |||
e0a53e44b2 | |||
4cf8379db0 | |||
c1c26bf0e0 | |||
29e2f4163d | |||
c3de80015d | |||
436cd9ce8b | |||
efb326be68 | |||
047903fe24 | |||
41068ddf89 | |||
196516ce85 | |||
7f647b127d | |||
a50a0874e7 | |||
b176be96fb | |||
1e5af07691 | |||
ecf8c902dc | |||
d3ede8546b | |||
53562ad260 | |||
6c8e7a7cb9 | |||
56c0245335 | |||
0cbd38a530 | |||
7c6da2c36a | |||
185140feb4 | |||
03c01a0b78 | |||
f823fdf241 | |||
fe87ddd8ff | |||
613ba12b05 | |||
8d7f66ecbc | |||
461aae253b | |||
a1b491cf0d | |||
edf0c82040 | |||
946a3c5a9a | |||
d8bc60c071 | |||
48477cd5c8 | |||
38df6293fe | |||
a5fe9e45fc | |||
9de5baa77a | |||
2daccd64e8 | |||
d0c68f9419 | |||
5f1dbfc510 | |||
90eee37c17 | |||
5630e61a74 | |||
eaad4b01dd | |||
3ab172f3d3 | |||
5450eba64b | |||
e2d6bb44d0 | |||
ffbd3a2449 | |||
2405a6d30c | |||
01085e5fd3 | |||
b5e11a72bf | |||
f55bbb6f84 |
19
.github/workflows/commit_check.yml
vendored
@ -4,19 +4,20 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
- '!master'
|
||||
|
||||
jobs:
|
||||
releases:
|
||||
name: Check commit
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FLUTTER_VERSION: "3.3.4"
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: subosito/flutter-action@v2
|
||||
- name: checkout all the submodules
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
flutter-version: '3.3.4'
|
||||
channel: 'stable'
|
||||
- run: flutter pub get
|
||||
- run: flutter format --set-exit-if-changed .
|
||||
- run: flutter analyze
|
||||
submodules: recursive
|
||||
- run: submodules/flutter/bin/flutter doctor
|
||||
- run: submodules/flutter/bin/flutter pub get
|
||||
- run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test
|
||||
- run: submodules/flutter/bin/flutter analyze lib test integration_test
|
||||
- run: submodules/flutter/bin/flutter test
|
30
.github/workflows/publish_ios.yml
vendored
@ -6,13 +6,12 @@ on:
|
||||
# Run the workflow whenever a new tag named 'v*' is pushed
|
||||
push:
|
||||
branches:
|
||||
- "!*"
|
||||
tags:
|
||||
- "v*"
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build_and_publish:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
# Point the `ruby/setup-ruby` action at this Gemfile, so it
|
||||
@ -21,21 +20,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out from git
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- run: submodules/flutter/bin/flutter doctor
|
||||
- run: submodules/flutter/bin/flutter pub get
|
||||
- run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test
|
||||
- run: submodules/flutter/bin/flutter analyze lib test integration_test
|
||||
- run: submodules/flutter/bin/flutter test
|
||||
|
||||
# Configure ruby according to our .ruby-version
|
||||
- name: Setup ruby & Bundler
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
# Set up flutter (feel free to adjust the version below)
|
||||
- name: Setup flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
cache: true
|
||||
flutter-version: 3.3.4
|
||||
- run: flutter pub get
|
||||
- run: flutter format --set-exit-if-changed .
|
||||
- run: flutter analyze
|
||||
|
||||
# Start an ssh-agent that will provide the SSH key from the
|
||||
# SSH_PRIVATE_KEY secret to `fastlane match`
|
||||
- name: Setup SSH key
|
||||
@ -44,11 +43,10 @@ jobs:
|
||||
run: |
|
||||
ssh-agent -a $SSH_AUTH_SOCK > /dev/null
|
||||
ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}"
|
||||
- name: Download dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build & Publish to TestFlight with Fastlane
|
||||
env:
|
||||
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
SSH_AUTH_SOCK: /tmp/ssh_agent.sock
|
||||
run: cd ios && bundle exec fastlane beta "build_name:${{ github.ref_name }}"
|
||||
run: cd ios && bundle exec fastlane beta
|
||||
|
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "flutter"]
|
||||
path = submodules/flutter
|
||||
url = https://github.com/flutter/flutter
|
@ -1,4 +1,4 @@
|
||||
include: package:very_good_analysis/analysis_options.2.4.0.yaml
|
||||
include: package:very_good_analysis/analysis_options.3.1.0.yaml
|
||||
linter:
|
||||
rules:
|
||||
parameter_assignments: false
|
||||
@ -6,4 +6,8 @@ linter:
|
||||
library_private_types_in_public_api: false
|
||||
omit_local_variable_types: false
|
||||
one_member_abstracts: false
|
||||
always_specify_types: true
|
||||
always_specify_types: true
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- "submodules/**"
|
||||
|
@ -64,12 +64,15 @@ android {
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
flutter {
|
||||
@ -79,3 +82,14 @@ flutter {
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
}
|
||||
|
||||
ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3]
|
||||
import com.android.build.OutputFile
|
||||
android.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))
|
||||
if (abiVersionCode != null) {
|
||||
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -37,15 +37,6 @@
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<!-- Displays an Android View that continues showing the launch screen
|
||||
Drawable until Flutter paints its first frame, then this splash
|
||||
screen fades out. A splash screen is useful to avoid any visual
|
||||
gap between the end of Android's launch screen and the painting of
|
||||
Flutter's first frame. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.SplashScreenDrawable"
|
||||
android:resource="@drawable/launch_background"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 539 B After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.8 KiB |
18
assets/eula.md
Normal file
@ -0,0 +1,18 @@
|
||||
## End-user License Agreement
|
||||
This policy applies to the usage of the Hacki app.
|
||||
|
||||
Please read this Mobile Application End User License Agreement (“EULA”) carefully before using the Hacki mobile application ("Mobile App"), which allows You to read and contribute to Hacker News from Your mobile device. This EULA forms a binding legal agreement between you (and any other entity on whose behalf you accept these terms) (collectively “You” or “Your”) and Hacki (each separately a “Party” and collectively the “Parties”) as of the date you download the Mobile App. Your use of the Mobile App is subject to this EULA.
|
||||
|
||||
### Changes to this EULA
|
||||
Hacki reserves the right to modify this EULA at any time and for any reason. You are responsible for complying with the updated EULA. Your continued use of the Mobile App indicates Your consent to the updated terms.
|
||||
|
||||
### No Included Maintenance and Support
|
||||
Hacki may deploy changes, updates, or enhancements to the Mobile App at any time. Hacki may provide maintenance and support for the Mobile App, but has no obligation whatsoever to furnish such services to You and may terminate such services at any time without notice.
|
||||
|
||||
### No Warranty
|
||||
Hacki expressly disclaims all warranties of any kind, whether express or implied.
|
||||
|
||||
The Mobile App is only available for supported devices and might not work on every device. Determining whether Your device is a supported or compatible device for use of the Mobile App is solely Your responsibility, and downloading the Mobile App is done at Your own risk. Smartsheet does not represent or warrant that the Mobile App and Your device are compatible or that the Mobile App will work on Your device.
|
||||
|
||||
### Your Consent
|
||||
By using the app, you consent to the end-user license agreement.
|
BIN
assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
Normal file
BIN
assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
Normal file
BIN
assets/fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
BIN
assets/fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
Normal file
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
Normal file
48
assets/privacy_policy.md
Normal file
@ -0,0 +1,48 @@
|
||||
## Privacy Policy
|
||||
This policy applies to all information collected or submitted on Hacki.
|
||||
|
||||
### Information we collect
|
||||
Hacki collects anonymous statistics such as crash reports and feature usage. These data are solely used to track app's health and are only stored locally on your device and only got sent to us when you choose to do so.
|
||||
|
||||
### Ads and analytics
|
||||
Hacki does not serve ads.
|
||||
|
||||
Hacki collects aggregate, anonymous statistics to improve the app but these data are only stored locally on your device and only got sent to us when you choose to do so.
|
||||
|
||||
### Information usage
|
||||
We use the information we collect to operate and improve our website, apps, and customer support.
|
||||
|
||||
We do not share personal information with outside parties except to the extent necessary to accomplish Hacki’s functionality.
|
||||
|
||||
We may disclose your information in response to subpoenas, court orders, or other legal requirements; to exercise our legal rights or defend against legal claims; to investigate, prevent, or take action regarding illegal activities, suspected fraud or abuse, violations of our policies; or to protect our rights and property.
|
||||
|
||||
### Security
|
||||
Hacki uses the official Hacker News API for fetching data from Hacker News.
|
||||
|
||||
When logging in, usernames and passwords are securely sent to Hacker News' servers for authentication.
|
||||
|
||||
### Third-party links and content
|
||||
Hacki displays links and content from third-party websites. These websites have their own independent privacy policies, and we have no responsibility or liability for their content or activities.
|
||||
|
||||
#### California Online Privacy Protection Act Compliance
|
||||
Hacki complies with the California Online Privacy Protection Act. We therefore will not distribute your personal information to outside parties without your consent.
|
||||
|
||||
#### Children’s Online Privacy Protection Act Compliance
|
||||
Hacki never collects or maintain information at our website from those we actually know are under 13, and no part of our website is structured to attract anyone under 13.
|
||||
|
||||
#### Information for European Union Customers
|
||||
By using Hacki and providing your information, you authorize us to collect, use, and store your information outside of the European Union.
|
||||
|
||||
#### International Transfers of Information
|
||||
Information may be processed, stored, and used outside of the country in which you are located. Data privacy laws vary across jurisdictions, and different laws may be applicable to your data depending on where it is processed, stored, or used.
|
||||
|
||||
### Your Consent
|
||||
By using the app, you consent to the privacy policy.
|
||||
|
||||
### Contacting Us
|
||||
If you have questions regarding this privacy policy, you may e-mail me us at jfeng@fastmail.com.
|
||||
|
||||
### Changes to this policy
|
||||
If we decide to change this privacy policy, we will post those changes on this page.
|
||||
|
||||
February 27, 2023: First published.
|
@ -1,4 +1,4 @@
|
||||
include: package:very_good_analysis/analysis_options.2.4.0.yaml
|
||||
include: package:very_good_analysis/analysis_options.3.1.0.yaml
|
||||
linter:
|
||||
rules:
|
||||
parameter_assignments: false
|
||||
|
@ -1,5 +1,82 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
typealias APNSHandler = ()->Void
|
||||
|
||||
let keyKey = "key"
|
||||
let valKey = "val"
|
||||
|
||||
final class SharedPrefsCore {
|
||||
fileprivate static let shared: SharedPrefsCore = SharedPrefsCore()
|
||||
|
||||
fileprivate func setBool(key: String?, val: Bool?) -> Bool {
|
||||
guard let key = key,
|
||||
let val = val else {
|
||||
return false
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let allVals = keyStore.dictionaryRepresentation;
|
||||
let allKeys = allVals.keys
|
||||
|
||||
// Limit is 1024, reserve rest slots for fav and pins.
|
||||
if allKeys.count >= 1000 {
|
||||
for key in allKeys.filter({ $0.contains("hasRead") }) {
|
||||
keyStore.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
keyStore.set(val, forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
fileprivate func getBool(key: String?) -> Bool {
|
||||
guard let key = key else {
|
||||
return false
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let val = keyStore.bool(forKey: key)
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
fileprivate func setStringList(key: String?, val: [String]?) -> Bool {
|
||||
guard let key = key,
|
||||
let val = val else {
|
||||
return false
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
keyStore.set(val, forKey: key)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fileprivate func getStringList(key: String?) -> [Any] {
|
||||
guard let key = key else {
|
||||
return [Any]()
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let list = keyStore.array(forKey: key) as [Any]? ?? [Any]()
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
fileprivate func clearAll() -> Bool{
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let allVals = keyStore.dictionaryRepresentation;
|
||||
let allKeys = allVals.keys
|
||||
|
||||
for key in allKeys.filter({ $0.contains("hasRead") }) {
|
||||
keyStore.removeObject(forKey: key)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
@ -7,46 +84,49 @@ public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
|
||||
let instance = SwiftSyncedSharedPreferencesPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "setBool":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("setBool"), object: nil, userInfo: info)
|
||||
let val = params[valKey] as? Bool
|
||||
let key = params[keyKey] as? String
|
||||
|
||||
let res = SharedPrefsCore.shared.setBool(key: key, val: val)
|
||||
result(res)
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
case "getBool":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("getBool"), object: nil, userInfo: info)
|
||||
let key = params[keyKey] as? String
|
||||
let res = SharedPrefsCore.shared.getBool(key: key)
|
||||
result(res)
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
case "setStringList":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("setStringList"), object: nil, userInfo: info)
|
||||
let val = params[valKey] as? [String]
|
||||
let key = params[keyKey] as? String
|
||||
|
||||
let res = SharedPrefsCore.shared.setStringList(key: key, val: val)
|
||||
result(res)
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
case "getStringList":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("getStringList"), object: nil, userInfo: info)
|
||||
let key = params[keyKey] as? String
|
||||
let res = SharedPrefsCore.shared.getStringList(key: key)
|
||||
result(res)
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
case "clearAll":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("clearAll"), object: nil, userInfo: info)
|
||||
let res = SharedPrefsCore.shared.clearAll()
|
||||
result(res)
|
||||
}
|
||||
|
||||
return
|
||||
|
2
fastlane/metadata/android/en-US/changelogs/76.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Fixed app icon.
|
||||
- Added font size setting to comments screen.
|
2
fastlane/metadata/android/en-US/changelogs/77.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Fixed app icon.
|
||||
- Added font size setting to comments screen.
|
1
fastlane/metadata/android/en-US/changelogs/78.txt
Normal file
@ -0,0 +1 @@
|
||||
- Fixed time machine.
|
1
fastlane/metadata/android/en-US/changelogs/79.txt
Normal file
@ -0,0 +1 @@
|
||||
- Fixed time machine.
|
0
fastlane/metadata/android/en-US/changelogs/81.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/84.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Customization of tab bar.
|
||||
- Option to enable swipe gesture for switching between tabs.
|
||||
- Access to action menu from home screen.
|
5
fastlane/metadata/android/en-US/changelogs/91.txt
Normal file
@ -0,0 +1,5 @@
|
||||
- Customization of tab bar.
|
||||
- Option to enable swipe gesture for switching between tabs.
|
||||
- Access to action menu from home screen.
|
||||
- Access to Wikipedia and Wiktionary from text selection toolbar.
|
||||
- Quotes and emphasis rendering.
|
5
fastlane/metadata/android/en-US/changelogs/92.txt
Normal file
@ -0,0 +1,5 @@
|
||||
- Customization of tab bar.
|
||||
- Option to enable swipe gesture for switching between tabs.
|
||||
- Access to action menu from home screen.
|
||||
- Access to Wikipedia and Wiktionary from text selection toolbar.
|
||||
- Quotes and emphasis rendering.
|
@ -17,20 +17,20 @@ GEM
|
||||
artifactory (3.0.15)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.636.0)
|
||||
aws-sdk-core (3.154.0)
|
||||
aws-partitions (1.680.0)
|
||||
aws-sdk-core (3.168.4)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.525.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.58.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
aws-sdk-kms (1.61.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.114.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
aws-sdk-s3 (1.117.2)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (1.5.1)
|
||||
aws-sigv4 (1.5.2)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
claide (1.1.0)
|
||||
@ -86,7 +86,7 @@ GEM
|
||||
escape (0.0.4)
|
||||
ethon (0.15.0)
|
||||
ffi (>= 1.15.0)
|
||||
excon (0.92.5)
|
||||
excon (0.95.0)
|
||||
faraday (1.10.2)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
@ -116,7 +116,7 @@ GEM
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.2.6)
|
||||
fastlane (2.210.1)
|
||||
fastlane (2.211.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
@ -159,9 +159,9 @@ GEM
|
||||
fourflusher (2.3.1)
|
||||
fuzzy_match (2.0.4)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.27.0)
|
||||
google-apis-core (>= 0.7.2, < 2.a)
|
||||
google-apis-core (0.9.0)
|
||||
google-apis-androidpublisher_v3 (0.32.0)
|
||||
google-apis-core (>= 0.9.1, < 2.a)
|
||||
google-apis-core (0.9.2)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
@ -170,27 +170,27 @@ GEM
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.14.0)
|
||||
google-apis-core (>= 0.7.2, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.10.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-apis-storage_v1 (0.17.0)
|
||||
google-apis-core (>= 0.7, < 2.a)
|
||||
google-apis-iamcredentials_v1 (0.16.0)
|
||||
google-apis-core (>= 0.9.1, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.12.0)
|
||||
google-apis-core (>= 0.9.1, < 2.a)
|
||||
google-apis-storage_v1 (0.19.0)
|
||||
google-apis-core (>= 0.9.0, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.0)
|
||||
google-cloud-storage (1.42.0)
|
||||
google-cloud-storage (1.44.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.17.0)
|
||||
google-apis-storage_v1 (~> 0.19.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.2.0)
|
||||
googleauth (1.3.0)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
@ -203,11 +203,11 @@ GEM
|
||||
httpclient (2.8.3)
|
||||
i18n (1.12.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jmespath (1.6.1)
|
||||
json (2.6.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.6.3)
|
||||
jwt (2.5.0)
|
||||
memoist (0.16.2)
|
||||
mini_magick (4.11.0)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.2)
|
||||
minitest (5.16.3)
|
||||
molinillo (0.8.0)
|
||||
|
@ -1,6 +1,3 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '11.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
|
@ -2,7 +2,11 @@ PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- ReachabilitySwift
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_email_sender (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview/Core (= 0.0.1)
|
||||
@ -12,7 +16,7 @@ PODS:
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage (3.3.1):
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- flutter_siri_suggestions (0.0.1):
|
||||
- Flutter
|
||||
@ -22,15 +26,19 @@ PODS:
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (5.0.0)
|
||||
- path_provider_ios (0.0.1):
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- ReachabilitySwift (5.0.0)
|
||||
- receive_sharing_intent (0.0.1):
|
||||
- Flutter
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_ios (0.0.1):
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite (0.0.2):
|
||||
- Flutter
|
||||
- FMDB (>= 2.7.5)
|
||||
@ -47,16 +55,19 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
|
||||
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
|
||||
- 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`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
@ -73,8 +84,12 @@ SPEC REPOS:
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_email_sender:
|
||||
:path: ".symlinks/plugins/flutter_email_sender/ios"
|
||||
flutter_inappwebview:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview/ios"
|
||||
flutter_local_notifications:
|
||||
@ -85,14 +100,16 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_siri_suggestions/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
path_provider_ios:
|
||||
:path: ".symlinks/plugins/path_provider_ios/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_ios:
|
||||
:path: ".symlinks/plugins/shared_preferences_ios/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
synced_shared_preferences:
|
||||
@ -108,26 +125,29 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
|
||||
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
|
||||
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
|
||||
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
|
||||
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
|
||||
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
|
||||
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
||||
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
|
||||
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
|
||||
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
|
||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||
|
||||
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
|
||||
PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
COCOAPODS: 1.11.3
|
||||
|
@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 51;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@ -21,7 +21,6 @@
|
||||
E530B1AD283B54DA004E8EB6 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E530B1AC283B54DA004E8EB6 /* ActionViewController.swift */; };
|
||||
E530B1B0283B54DA004E8EB6 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E530B1AE283B54DA004E8EB6 /* MainInterface.storyboard */; };
|
||||
E530B1B4283B54DA004E8EB6 /* Action Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E530B1A6283B54DA004E8EB6 /* Action Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
E54B4753282B3B8900579261 /* HackiCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E54B4752282B3B8900579261 /* HackiCore.swift */; };
|
||||
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E575B6F027EBC6DA002B1508 /* CloudKit.framework */; };
|
||||
FC507E94AA7767C155787DB3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
@ -56,7 +55,7 @@
|
||||
};
|
||||
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
buildActionMask = 8;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
@ -64,7 +63,7 @@
|
||||
E530B1B4283B54DA004E8EB6 /* Action Extension.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
runOnlyForDeploymentPostprocessing = 1;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
@ -97,7 +96,6 @@
|
||||
E530B1AF283B54DA004E8EB6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
|
||||
E530B1B1283B54DA004E8EB6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
E530B1B9283B54E4004E8EB6 /* Action Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Action Extension.entitlements"; sourceTree = "<group>"; };
|
||||
E54B4752282B3B8900579261 /* HackiCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackiCore.swift; sourceTree = "<group>"; };
|
||||
E575B6EF27EBC6C6002B1508 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
E575B6F027EBC6DA002B1508 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; };
|
||||
E59F28EE283B477D00512089 /* Share Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Share Extension.entitlements"; sourceTree = "<group>"; };
|
||||
@ -177,7 +175,6 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
E54B4752282B3B8900579261 /* HackiCore.swift */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@ -363,6 +360,7 @@
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
@ -416,6 +414,7 @@
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
@ -437,7 +436,6 @@
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
E54B4753282B3B8900579261 /* HackiCore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -569,17 +567,19 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.news";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.32;
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -709,17 +709,19 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.news";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.32;
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -743,17 +745,19 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.news";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.32;
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -778,7 +782,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@ -821,7 +825,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@ -861,7 +865,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@ -903,7 +907,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@ -948,7 +952,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@ -990,7 +994,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
@ -1,9 +1,9 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
import workmanager
|
||||
import shared_preferences_ios
|
||||
import shared_preferences_foundation
|
||||
import flutter_secure_storage
|
||||
import path_provider_ios
|
||||
import path_provider_foundation
|
||||
import flutter_local_notifications
|
||||
|
||||
@UIApplicationMain
|
||||
@ -16,10 +16,10 @@ import flutter_local_notifications
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.delegate = self
|
||||
|
||||
HackiCore.start()
|
||||
|
||||
WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!)
|
||||
|
||||
WorkmanagerPlugin.registerTask(withIdentifier: "workmanager.background.task")
|
||||
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
@ -28,8 +28,8 @@ import flutter_local_notifications
|
||||
|
||||
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
FLTSharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")!)
|
||||
FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.pathprovider.PathProviderPlugin")!)
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")!)
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.pathprovider.PathProviderPlugin")!)
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin")!)
|
||||
}
|
||||
|
||||
|
@ -1,134 +0,0 @@
|
||||
//
|
||||
// HackiCore.swift
|
||||
// Runner
|
||||
//
|
||||
// Created by Jiaqi Feng on 5/10/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Flutter
|
||||
|
||||
extension Notification.Name {
|
||||
static let setBool = Notification.Name("setBool")
|
||||
static let getBool = Notification.Name("getBool")
|
||||
static let setStringList = Notification.Name("setStringList")
|
||||
static let getStringList = Notification.Name("getStringList")
|
||||
static let clearAll = Notification.Name("clearAll")
|
||||
}
|
||||
|
||||
typealias APNSHandler = ()->Void
|
||||
|
||||
final class HackiCore: NSObject {
|
||||
private static let keyKey = "key"
|
||||
private static let valKey = "val"
|
||||
|
||||
private static let shared: HackiCore = HackiCore()
|
||||
private let notificationCenter = NotificationCenter.default
|
||||
|
||||
// Called at app launch
|
||||
class func start() {
|
||||
shared.registerNotifications()
|
||||
}
|
||||
|
||||
private class func setupFlutterEvent(channelName: String, handler: NSObjectProtocol & FlutterStreamHandler) {
|
||||
guard let rootVC = UIApplication.shared.delegate?.window.unsafelyUnwrapped?.rootViewController as? FlutterViewController else { return }
|
||||
let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: rootVC.binaryMessenger)
|
||||
eventChannel.setStreamHandler(handler)
|
||||
}
|
||||
|
||||
private func registerNotifications() {
|
||||
// SyncedSharedPreferences
|
||||
notificationCenter.addObserver(self, selector: #selector(setBool(_:)), name: .setBool, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(getBool(_:)), name: .getBool, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(setStringList(_:)), name: .setStringList, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(getStringList(_:)), name: .getStringList, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(clearAll(_:)), name: .clearAll, object: nil)
|
||||
}
|
||||
|
||||
@objc private func setBool(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
guard
|
||||
let params = notification.userInfo?["params"] as? [String: Any],
|
||||
let key = params[HackiCore.keyKey] as? String,
|
||||
let val = params[HackiCore.valKey] as? Bool else {
|
||||
resultCompletionBlock(false)
|
||||
return
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let allVals = keyStore.dictionaryRepresentation;
|
||||
let allKeys = allVals.keys
|
||||
|
||||
// Limit is 1024, reserve rest slots for fav and pins.
|
||||
if allKeys.count >= 1000 {
|
||||
for key in allKeys.filter({ $0.contains("hasRead") }) {
|
||||
keyStore.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
keyStore.set(val, forKey: key)
|
||||
|
||||
resultCompletionBlock(true)
|
||||
}
|
||||
|
||||
@objc private func getBool(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
guard
|
||||
let params = notification.userInfo?["params"] as? [String: Any],
|
||||
let key = params[HackiCore.keyKey] as? String else {
|
||||
resultCompletionBlock(false)
|
||||
return
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let val = keyStore.bool(forKey: key)
|
||||
|
||||
resultCompletionBlock(val)
|
||||
}
|
||||
|
||||
@objc private func setStringList(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
guard
|
||||
let params = notification.userInfo?["params"] as? [String: Any],
|
||||
let key = params[HackiCore.keyKey] as? String,
|
||||
let val = params[HackiCore.valKey] as? [String] else {
|
||||
resultCompletionBlock(false)
|
||||
return
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
keyStore.set(val, forKey: key)
|
||||
|
||||
resultCompletionBlock(true)
|
||||
}
|
||||
|
||||
@objc private func getStringList(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
guard
|
||||
let params = notification.userInfo?["params"] as? [String: Any],
|
||||
let key = params[HackiCore.keyKey] as? String else {
|
||||
resultCompletionBlock(false)
|
||||
return
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let list = keyStore.array(forKey: key) as [Any]? ?? [Any]()
|
||||
|
||||
resultCompletionBlock(list)
|
||||
}
|
||||
|
||||
@objc private func clearAll(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let allVals = keyStore.dictionaryRepresentation;
|
||||
let allKeys = allVals.keys
|
||||
|
||||
for key in allKeys.filter({ $0.contains("hasRead") }) {
|
||||
keyStore.removeObject(forKey: key)
|
||||
}
|
||||
|
||||
resultCompletionBlock(true)
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@ -38,7 +38,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
@ -72,5 +72,9 @@
|
||||
<array>
|
||||
<string>applinks:example.com</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -31,10 +31,6 @@ platform :ios do
|
||||
|
||||
is_example_repo = ENV['CI'] && ENV['GITHUB_REPOSITORY'] == 'jorgenpt/flutter_github_example'
|
||||
|
||||
if !is_example_repo && APP_IDENTIFIER == 'no.tjer.HelloWorld' then
|
||||
UI.user_error! "You need to update your Fastfile to use your own `APP_IDENTIFIER`"
|
||||
end
|
||||
|
||||
# Download code signing certificates using `match` (and the `MATCH_PASSWORD` secret)
|
||||
sync_code_signing(
|
||||
type: "appstore",
|
||||
@ -42,15 +38,6 @@ platform :ios do
|
||||
readonly: true
|
||||
)
|
||||
|
||||
if !is_example_repo then
|
||||
if APPSTORECONNECT_ISSUER_ID == '69a6de83-feb7-47e3-e053-5b8c7c11a4d1' then
|
||||
UI.user_error! "You need to update your Fastfile to use your own `APPSTORECONNECT_ISSUER_ID`"
|
||||
end
|
||||
if APPSTORECONNECT_KEY_ID == 'YRQDJRKMR9' then
|
||||
UI.user_error! "You need to update your Fastfile to use your own `APPSTORECONNECT_KEY_ID`"
|
||||
end
|
||||
end
|
||||
|
||||
# We expose the key data using `APP_STORE_CONNECT_API_KEY_KEY` secret on GH
|
||||
app_store_connect_api_key(
|
||||
key_id: APPSTORECONNECT_KEY_ID,
|
||||
@ -59,21 +46,18 @@ platform :ios do
|
||||
latest_testflight_build_number
|
||||
# Figure out the build number (and optionally build name)
|
||||
new_build_number = ( + 1)
|
||||
extra_config_args = []
|
||||
if options.key?(:build_name) then
|
||||
extra_config_args = ["--build-name", options[:build_name].delete_prefix('v')]
|
||||
end
|
||||
|
||||
# Prep the xcodeproject from Flutter without building (`--config-only`)
|
||||
sh(
|
||||
"flutter", "build", "ios", "--config-only",
|
||||
"/Users/runner/work/Hacki/Hacki/submodules/flutter/bin/flutter", "build", "ios", "--config-only",
|
||||
"--release", "--no-pub", "--no-codesign",
|
||||
"--build-number", new_build_number.to_s,
|
||||
*extra_config_args
|
||||
"--build-number", new_build_number.to_s
|
||||
)
|
||||
|
||||
version = get_version_number(xcodeproj: "Runner.xcodeproj", target: 'Runner')
|
||||
|
||||
increment_version_number(
|
||||
version_number: options[:build_name].delete_prefix('v').delete_suffix('-rc')
|
||||
version_number: version
|
||||
)
|
||||
|
||||
increment_build_number({
|
||||
@ -93,4 +77,4 @@ latest_testflight_build_number
|
||||
skip_waiting_for_build_processing: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -20,7 +20,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
super(AuthState.init()) {
|
||||
super(const AuthState.init()) {
|
||||
on<AuthInitialize>(onInitialize);
|
||||
on<AuthLogin>(onLogin);
|
||||
on<AuthLogout>(onLogout);
|
||||
@ -41,20 +41,25 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
await _authRepository.loggedIn.then((bool loggedIn) async {
|
||||
if (loggedIn) {
|
||||
final String? username = await _authRepository.username;
|
||||
final User user =
|
||||
await _storiesRepository.fetchUserBy(userId: username!);
|
||||
User? user = await _storiesRepository.fetchUser(id: username!);
|
||||
|
||||
/// According to Hacker News' API documentation,
|
||||
/// if user has no public activity (posting a comment or story),
|
||||
/// then it will not be available from the API.
|
||||
user ??= User.emptyWithId(username);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoggedIn: true,
|
||||
user: user,
|
||||
status: AuthStatus.loaded,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AuthStatus.loaded,
|
||||
isLoggedIn: false,
|
||||
status: AuthStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -84,11 +89,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
if (successful) {
|
||||
final User user =
|
||||
await _storiesRepository.fetchUserBy(userId: event.username);
|
||||
final User? user = await _storiesRepository.fetchUser(id: event.username);
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user,
|
||||
user: user ?? User.emptyWithId(event.username),
|
||||
isLoggedIn: true,
|
||||
status: AuthStatus.loaded,
|
||||
),
|
||||
@ -101,7 +105,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
Future<void> onLogout(AuthLogout event, Emitter<AuthState> emit) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: User.empty(),
|
||||
user: const User.empty(),
|
||||
isLoggedIn: false,
|
||||
agreedToEULA: false,
|
||||
),
|
||||
|
@ -14,8 +14,8 @@ class AuthState extends Equatable {
|
||||
required this.agreedToEULA,
|
||||
});
|
||||
|
||||
AuthState.init()
|
||||
: user = User.empty(),
|
||||
const AuthState.init()
|
||||
: user = const User.empty(),
|
||||
isLoggedIn = false,
|
||||
status = AuthStatus.loaded,
|
||||
agreedToEULA = false;
|
||||
|
@ -17,11 +17,13 @@ part 'stories_state.dart';
|
||||
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoriesBloc({
|
||||
required PreferenceCubit preferenceCubit,
|
||||
required FilterCubit filterCubit,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
Logger? logger,
|
||||
}) : _preferenceCubit = preferenceCubit,
|
||||
_filterCubit = filterCubit,
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
_storiesRepository =
|
||||
@ -37,6 +39,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
on<StoryRead>(onStoryRead);
|
||||
on<StoriesLoaded>(onStoriesLoaded);
|
||||
on<StoriesDownload>(onDownload);
|
||||
on<StoriesCancelDownload>(onCancelDownload);
|
||||
on<StoryDownloaded>(onStoryDownloaded);
|
||||
on<StoriesExitOffline>(onExitOffline);
|
||||
on<StoriesPageSizeChanged>(onPageSizeChanged);
|
||||
@ -44,6 +47,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final FilterCubit _filterCubit;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
@ -55,75 +59,72 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
static const int _tabletSmallPageSize = 15;
|
||||
static const int _tabletLargePageSize = 25;
|
||||
|
||||
/// Types of story to be shown in the tab bar.
|
||||
static const Set<StoryType> types = <StoryType>{
|
||||
StoryType.top,
|
||||
StoryType.best,
|
||||
StoryType.latest,
|
||||
StoryType.ask,
|
||||
StoryType.show,
|
||||
};
|
||||
|
||||
Future<void> onInitialize(
|
||||
StoriesInitialize event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
_streamSubscription ??=
|
||||
_preferenceCubit.stream.listen((PreferenceState event) {
|
||||
final bool isComplexTile = event.showComplexStoryTile;
|
||||
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
|
||||
final bool isComplexTile = event.complexStoryTileEnabled;
|
||||
final int pageSize = getPageSize(isComplexTile: isComplexTile);
|
||||
|
||||
if (pageSize != state.currentPageSize) {
|
||||
add(StoriesPageSizeChanged(pageSize: pageSize));
|
||||
}
|
||||
});
|
||||
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
|
||||
final bool isComplexTile = _preferenceCubit.state.showComplexStoryTile;
|
||||
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
|
||||
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
|
||||
final int pageSize = getPageSize(isComplexTile: isComplexTile);
|
||||
emit(
|
||||
const StoriesState.init().copyWith(
|
||||
offlineReading: hasCachedStories,
|
||||
isOfflineReading: hasCachedStories &&
|
||||
// Only go into offline mode in the next session.
|
||||
state.downloadStatus == StoriesDownloadStatus.initial,
|
||||
currentPageSize: pageSize,
|
||||
downloadStatus: state.downloadStatus,
|
||||
storiesDownloaded: state.storiesDownloaded,
|
||||
storiesToBeDownloaded: state.storiesToBeDownloaded,
|
||||
),
|
||||
);
|
||||
for (final StoryType type in types) {
|
||||
await loadStories(of: type, emit: emit);
|
||||
for (final StoryType type in StoryType.values) {
|
||||
await loadStories(type: type, emit: emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadStories({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required Emitter<StoriesState> emit,
|
||||
}) async {
|
||||
if (state.offlineReading) {
|
||||
final List<int> ids = await _offlineRepository.getCachedStoryIds(of: of);
|
||||
if (state.isOfflineReading) {
|
||||
final List<int> ids =
|
||||
await _offlineRepository.getCachedStoryIds(type: type);
|
||||
emit(
|
||||
state
|
||||
.copyWithStoryIdsUpdated(of: of, to: ids)
|
||||
.copyWithCurrentPageUpdated(of: of, to: 0),
|
||||
.copyWithStoryIdsUpdated(type: type, to: ids)
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0),
|
||||
);
|
||||
_offlineRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
|
||||
)
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: of));
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: of));
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
} else {
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(of: of);
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
|
||||
emit(
|
||||
state
|
||||
.copyWithStoryIdsUpdated(of: of, to: ids)
|
||||
.copyWithCurrentPageUpdated(of: of, to: 0),
|
||||
.copyWithStoryIdsUpdated(type: type, to: ids)
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0),
|
||||
);
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: of));
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: of));
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -134,35 +135,37 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
) async {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
type: event.type,
|
||||
to: StoriesStatus.loading,
|
||||
),
|
||||
);
|
||||
|
||||
if (state.offlineReading) {
|
||||
if (state.isOfflineReading) {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
type: event.type,
|
||||
to: StoriesStatus.loaded,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWithRefreshed(of: event.type));
|
||||
await loadStories(of: event.type, emit: emit);
|
||||
emit(state.copyWithRefreshed(type: event.type));
|
||||
await loadStories(type: event.type, emit: emit);
|
||||
}
|
||||
}
|
||||
|
||||
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
type: event.type,
|
||||
to: StoriesStatus.loading,
|
||||
),
|
||||
);
|
||||
|
||||
final int currentPage = state.currentPageByType[event.type]!;
|
||||
final int len = state.storyIdsByType[event.type]!.length;
|
||||
emit(state.copyWithCurrentPageUpdated(of: event.type, to: currentPage + 1));
|
||||
emit(
|
||||
state.copyWithCurrentPageUpdated(type: event.type, to: currentPage + 1),
|
||||
);
|
||||
final int currentPageSize = state.currentPageSize;
|
||||
final int lower = currentPageSize * (currentPage + 1);
|
||||
int upper = currentPageSize + lower;
|
||||
@ -172,7 +175,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
upper = len;
|
||||
}
|
||||
|
||||
if (state.offlineReading) {
|
||||
if (state.isOfflineReading) {
|
||||
_offlineRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
@ -212,7 +215,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
} else {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
type: event.type,
|
||||
to: StoriesStatus.loaded,
|
||||
),
|
||||
);
|
||||
@ -224,17 +227,24 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
|
||||
final bool hidden = _filterCubit.state.keywords.any(
|
||||
(String keyword) =>
|
||||
event.story.title.toLowerCase().contains(keyword) ||
|
||||
event.story.text.toLowerCase().contains(keyword),
|
||||
);
|
||||
emit(
|
||||
state.copyWithStoryAdded(
|
||||
of: event.type,
|
||||
story: event.story,
|
||||
type: event.type,
|
||||
story: event.story.copyWith(hidden: hidden),
|
||||
hasRead: hasRead,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
|
||||
emit(state.copyWithStatusUpdated(of: event.type, to: StoriesStatus.loaded));
|
||||
emit(
|
||||
state.copyWithStatusUpdated(type: event.type, to: StoriesStatus.loaded),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onDownload(
|
||||
@ -252,12 +262,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
await _offlineRepository.deleteAllComments();
|
||||
|
||||
final Set<int> prioritizedIds = <int>{};
|
||||
final List<StoryType> prioritizedTypes = <StoryType>[...types]
|
||||
|
||||
/// Prioritizing all types of stories except StoryType.latest since
|
||||
/// new stories tend to have less or no comment at all.
|
||||
final List<StoryType> prioritizedTypes = <StoryType>[...StoryType.values]
|
||||
..remove(StoryType.latest);
|
||||
|
||||
for (final StoryType type in prioritizedTypes) {
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(of: type);
|
||||
await _offlineRepository.cacheStoryIds(of: type, ids: ids);
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
|
||||
await _offlineRepository.cacheStoryIds(type: type, ids: ids);
|
||||
prioritizedIds.addAll(ids);
|
||||
}
|
||||
|
||||
@ -277,9 +290,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
|
||||
final Set<int> latestIds = <int>{};
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(
|
||||
of: StoryType.latest,
|
||||
type: StoryType.latest,
|
||||
);
|
||||
await _offlineRepository.cacheStoryIds(of: StoryType.latest, ids: ids);
|
||||
await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids);
|
||||
latestIds.addAll(ids);
|
||||
|
||||
await fetchAndCacheStories(
|
||||
@ -296,13 +309,41 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onCancelDownload(
|
||||
StoriesCancelDownload event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
downloadStatus: StoriesDownloadStatus.canceled,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> fetchAndCacheStories(
|
||||
Iterable<int> ids, {
|
||||
required bool includingWebPage,
|
||||
required bool isPrioritized,
|
||||
}) async {
|
||||
final List<StreamSubscription<Comment>> downloadStreams =
|
||||
<StreamSubscription<Comment>>[];
|
||||
for (final int id in ids) {
|
||||
final Story? story = await _storiesRepository.fetchStoryBy(id);
|
||||
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
|
||||
_logger.d('aborting downloading');
|
||||
|
||||
for (final StreamSubscription<Comment> stream in downloadStreams) {
|
||||
await stream.cancel();
|
||||
}
|
||||
|
||||
_logger.d('deleting downloaded contents');
|
||||
await _offlineRepository.deleteAllStoryIds();
|
||||
await _offlineRepository.deleteAllStories();
|
||||
await _offlineRepository.deleteAllComments();
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.d('fetching story $id');
|
||||
final Story? story = await _storiesRepository.fetchStory(id: id);
|
||||
|
||||
if (story == null) {
|
||||
if (isPrioritized) {
|
||||
@ -325,15 +366,37 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
await _offlineRepository.cacheUrl(url: story.url);
|
||||
}
|
||||
|
||||
_storiesRepository
|
||||
/// Not awaiting the completion of comments stream because otherwise
|
||||
/// it's going to take forever to finish downloading all the stories
|
||||
/// since we need to make a single http call for each comment.
|
||||
///
|
||||
/// In other words, we are prioritizing the story itself instead of
|
||||
/// the comments in the story.
|
||||
late final StreamSubscription<Comment>? downloadStream;
|
||||
downloadStream = _storiesRepository
|
||||
.fetchAllChildrenComments(ids: story.kids)
|
||||
.whereType<Comment>()
|
||||
.listen(
|
||||
(Comment comment) => unawaited(
|
||||
_offlineRepository.cacheComment(comment: comment),
|
||||
),
|
||||
)
|
||||
.onDone(() => add(StoryDownloaded(skipped: false)));
|
||||
(Comment comment) {
|
||||
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
|
||||
_logger.d('aborting downloading from comments stream');
|
||||
downloadStream?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.d('fetched comment ${comment.id}');
|
||||
unawaited(
|
||||
_offlineRepository.cacheComment(comment: comment),
|
||||
);
|
||||
},
|
||||
)..onDone(() {
|
||||
_logger.d(
|
||||
'''finished downloading story ${story.id} with ${story.descendants} comments''',
|
||||
);
|
||||
add(StoryDownloaded(skipped: false));
|
||||
});
|
||||
|
||||
downloadStreams.add(downloadStream);
|
||||
}
|
||||
}
|
||||
|
||||
@ -374,7 +437,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoriesPageSizeChanged event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
emit(const StoriesState.init());
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
|
||||
@ -386,7 +448,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
await _offlineRepository.deleteAllStories();
|
||||
await _offlineRepository.deleteAllComments();
|
||||
await _offlineRepository.deleteAllWebPages();
|
||||
emit(state.copyWith(offlineReading: false));
|
||||
emit(state.copyWith(isOfflineReading: false));
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
|
||||
@ -418,7 +480,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
|
||||
bool hasRead(Story story) => state.readStoriesIds.contains(story.id);
|
||||
|
||||
int _getPageSize({required bool isComplexTile}) {
|
||||
int getPageSize({required bool isComplexTile}) {
|
||||
int pageSize = isComplexTile ? _smallPageSize : _largePageSize;
|
||||
|
||||
if (deviceScreenType != DeviceScreenType.mobile) {
|
||||
|
@ -46,6 +46,13 @@ class StoriesDownload extends StoriesEvent {
|
||||
List<Object?> get props => <Object?>[includingWebPage];
|
||||
}
|
||||
|
||||
class StoriesCancelDownload extends StoriesEvent {
|
||||
StoriesCancelDownload();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class StoryDownloaded extends StoriesEvent {
|
||||
StoryDownloaded({required this.skipped});
|
||||
|
||||
|
@ -11,6 +11,7 @@ enum StoriesDownloadStatus {
|
||||
downloading,
|
||||
finished,
|
||||
failure,
|
||||
canceled,
|
||||
}
|
||||
|
||||
class StoriesState extends Equatable {
|
||||
@ -20,7 +21,7 @@ class StoriesState extends Equatable {
|
||||
required this.statusByType,
|
||||
required this.currentPageByType,
|
||||
required this.readStoriesIds,
|
||||
required this.offlineReading,
|
||||
required this.isOfflineReading,
|
||||
required this.downloadStatus,
|
||||
required this.currentPageSize,
|
||||
required this.storiesDownloaded,
|
||||
@ -34,7 +35,6 @@ class StoriesState extends Equatable {
|
||||
StoryType.latest: <Story>[],
|
||||
StoryType.ask: <Story>[],
|
||||
StoryType.show: <Story>[],
|
||||
StoryType.jobs: <Story>[],
|
||||
},
|
||||
this.storyIdsByType = const <StoryType, List<int>>{
|
||||
StoryType.top: <int>[],
|
||||
@ -42,7 +42,6 @@ class StoriesState extends Equatable {
|
||||
StoryType.latest: <int>[],
|
||||
StoryType.ask: <int>[],
|
||||
StoryType.show: <int>[],
|
||||
StoryType.jobs: <int>[],
|
||||
},
|
||||
this.statusByType = const <StoryType, StoriesStatus>{
|
||||
StoryType.top: StoriesStatus.initial,
|
||||
@ -50,7 +49,6 @@ class StoriesState extends Equatable {
|
||||
StoryType.latest: StoriesStatus.initial,
|
||||
StoryType.ask: StoriesStatus.initial,
|
||||
StoryType.show: StoriesStatus.initial,
|
||||
StoryType.jobs: StoriesStatus.initial,
|
||||
},
|
||||
this.currentPageByType = const <StoryType, int>{
|
||||
StoryType.top: 0,
|
||||
@ -58,9 +56,8 @@ class StoriesState extends Equatable {
|
||||
StoryType.latest: 0,
|
||||
StoryType.ask: 0,
|
||||
StoryType.show: 0,
|
||||
StoryType.jobs: 0,
|
||||
},
|
||||
}) : offlineReading = false,
|
||||
}) : isOfflineReading = false,
|
||||
downloadStatus = StoriesDownloadStatus.initial,
|
||||
currentPageSize = 0,
|
||||
readStoriesIds = const <int>{},
|
||||
@ -73,7 +70,7 @@ class StoriesState extends Equatable {
|
||||
final Map<StoryType, int> currentPageByType;
|
||||
final Set<int> readStoriesIds;
|
||||
final StoriesDownloadStatus downloadStatus;
|
||||
final bool offlineReading;
|
||||
final bool isOfflineReading;
|
||||
final int currentPageSize;
|
||||
final int storiesDownloaded;
|
||||
final int storiesToBeDownloaded;
|
||||
@ -85,7 +82,7 @@ class StoriesState extends Equatable {
|
||||
Map<StoryType, int>? currentPageByType,
|
||||
Set<int>? readStoriesIds,
|
||||
StoriesDownloadStatus? downloadStatus,
|
||||
bool? offlineReading,
|
||||
bool? isOfflineReading,
|
||||
int? currentPageSize,
|
||||
int? storiesDownloaded,
|
||||
int? storiesToBeDownloaded,
|
||||
@ -96,7 +93,7 @@ class StoriesState extends Equatable {
|
||||
statusByType: statusByType ?? this.statusByType,
|
||||
currentPageByType: currentPageByType ?? this.currentPageByType,
|
||||
readStoriesIds: readStoriesIds ?? this.readStoriesIds,
|
||||
offlineReading: offlineReading ?? this.offlineReading,
|
||||
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
|
||||
downloadStatus: downloadStatus ?? this.downloadStatus,
|
||||
currentPageSize: currentPageSize ?? this.currentPageSize,
|
||||
storiesDownloaded: storiesDownloaded ?? this.storiesDownloaded,
|
||||
@ -106,13 +103,13 @@ class StoriesState extends Equatable {
|
||||
}
|
||||
|
||||
StoriesState copyWithStoryAdded({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required Story story,
|
||||
required bool hasRead,
|
||||
}) {
|
||||
final Map<StoryType, List<Story>> newMap =
|
||||
Map<StoryType, List<Story>>.from(storiesByType);
|
||||
newMap[of] = List<Story>.from(newMap[of]!)..add(story);
|
||||
newMap[type] = List<Story>.from(newMap[type]!)..add(story);
|
||||
return copyWith(
|
||||
storiesByType: newMap,
|
||||
readStoriesIds: <int>{
|
||||
@ -123,54 +120,54 @@ class StoriesState extends Equatable {
|
||||
}
|
||||
|
||||
StoriesState copyWithStoryIdsUpdated({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required List<int> to,
|
||||
}) {
|
||||
final Map<StoryType, List<int>> newMap =
|
||||
Map<StoryType, List<int>>.from(storyIdsByType);
|
||||
newMap[of] = to;
|
||||
newMap[type] = to;
|
||||
return copyWith(
|
||||
storyIdsByType: newMap,
|
||||
);
|
||||
}
|
||||
|
||||
StoriesState copyWithStatusUpdated({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required StoriesStatus to,
|
||||
}) {
|
||||
final Map<StoryType, StoriesStatus> newMap =
|
||||
Map<StoryType, StoriesStatus>.from(statusByType);
|
||||
newMap[of] = to;
|
||||
newMap[type] = to;
|
||||
return copyWith(
|
||||
statusByType: newMap,
|
||||
);
|
||||
}
|
||||
|
||||
StoriesState copyWithCurrentPageUpdated({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required int to,
|
||||
}) {
|
||||
final Map<StoryType, int> newMap =
|
||||
Map<StoryType, int>.from(currentPageByType);
|
||||
newMap[of] = to;
|
||||
newMap[type] = to;
|
||||
return copyWith(
|
||||
currentPageByType: newMap,
|
||||
);
|
||||
}
|
||||
|
||||
StoriesState copyWithRefreshed({required StoryType of}) {
|
||||
StoriesState copyWithRefreshed({required StoryType type}) {
|
||||
final Map<StoryType, List<Story>> newStoriesMap =
|
||||
Map<StoryType, List<Story>>.from(storiesByType);
|
||||
newStoriesMap[of] = <Story>[];
|
||||
newStoriesMap[type] = <Story>[];
|
||||
final Map<StoryType, List<int>> newStoryIdsMap =
|
||||
Map<StoryType, List<int>>.from(storyIdsByType);
|
||||
newStoryIdsMap[of] = <int>[];
|
||||
newStoryIdsMap[type] = <int>[];
|
||||
final Map<StoryType, StoriesStatus> newStatusMap =
|
||||
Map<StoryType, StoriesStatus>.from(statusByType);
|
||||
newStatusMap[of] = StoriesStatus.loading;
|
||||
newStatusMap[type] = StoriesStatus.loading;
|
||||
final Map<StoryType, int> newCurrentPageMap =
|
||||
Map<StoryType, int>.from(currentPageByType);
|
||||
newCurrentPageMap[of] = 0;
|
||||
newCurrentPageMap[type] = 0;
|
||||
return copyWith(
|
||||
storiesByType: newStoriesMap,
|
||||
storyIdsByType: newStoryIdsMap,
|
||||
@ -186,7 +183,7 @@ class StoriesState extends Equatable {
|
||||
statusByType,
|
||||
currentPageByType,
|
||||
readStoriesIds,
|
||||
offlineReading,
|
||||
isOfflineReading,
|
||||
downloadStatus,
|
||||
currentPageSize,
|
||||
storiesDownloaded,
|
||||
|
@ -1,15 +1,26 @@
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
|
||||
abstract class Constants {
|
||||
static const String endUserAgreementLink =
|
||||
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45';
|
||||
'https://github.com/Livinglist/Hacki/blob/master/assets/eula.md';
|
||||
static const String privacyPolicyLink =
|
||||
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
|
||||
static const String hackerNewsLogoLink =
|
||||
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
|
||||
static const String portfolioLink = 'https://livinglist.github.io';
|
||||
static const String portfolioLink = 'https://github.com/Livinglist';
|
||||
static const String githubLink = 'https://github.com/Livinglist/Hacki';
|
||||
static const String appStoreLink =
|
||||
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
|
||||
static const String googlePlayLink =
|
||||
'https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US';
|
||||
static const String sponsorLink = 'https://github.com/sponsors/Livinglist';
|
||||
static const String guidelineLink =
|
||||
'https://news.ycombinator.com/newsguidelines.html';
|
||||
static const String githubIssueLink =
|
||||
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
|
||||
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
|
||||
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
|
||||
static const String supportEmail = 'georgefung98@gmail.com';
|
||||
|
||||
static const String _imagePath = 'assets/images';
|
||||
static const String hackerNewsLogoPath = '$_imagePath/hacker_news_logo.png';
|
||||
@ -20,6 +31,8 @@ abstract class Constants {
|
||||
'$_imagePath/comment_tile_right_slide.png';
|
||||
static const String commentTileTopTapPath =
|
||||
'$_imagePath/comment_tile_top_tap.png';
|
||||
static const String logFilename = 'hacki_log.txt';
|
||||
static const String previousLogFileName = 'old_hacki_log.txt';
|
||||
|
||||
/// Feature ids for feature discovery.
|
||||
static const String featureAddStoryToFavList = 'add_story_to_fav_list';
|
||||
@ -27,16 +40,16 @@ abstract class Constants {
|
||||
static const String featureLogIn = 'log_in';
|
||||
static const String featurePinToTop = 'pin_to_top';
|
||||
|
||||
static const List<String> happyFaces = <String>[
|
||||
static final String happyFace = <String>[
|
||||
'(๑•̀ㅂ•́)و✧',
|
||||
'( ͡• ͜ʖ ͡•)',
|
||||
'( ͡~ ͜ʖ ͡°)',
|
||||
'٩(˘◡˘)۶',
|
||||
'(─‿‿─)',
|
||||
'(¬‿¬)',
|
||||
];
|
||||
].pickRandomly()!;
|
||||
|
||||
static const List<String> sadFaces = <String>[
|
||||
static final String sadFace = <String>[
|
||||
'ಥ_ಥ',
|
||||
'(╯°□°)╯︵ ┻━┻',
|
||||
r'¯\_(ツ)_/¯',
|
||||
@ -46,5 +59,20 @@ abstract class Constants {
|
||||
'(ㆆ_ㆆ)',
|
||||
'ʕ•́ᴥ•̀ʔっ',
|
||||
'(ㆆ_ㆆ)',
|
||||
];
|
||||
].pickRandomly()!;
|
||||
|
||||
static final String magicWord = <String>[
|
||||
'to be over the rainbow!',
|
||||
'to infinity and beyond!',
|
||||
'to see the future.',
|
||||
].pickRandomly()!;
|
||||
|
||||
static final String errorMessage = 'Something went wrong...$sadFace';
|
||||
static final String loginErrorMessage =
|
||||
'''Failed to log in $sadFace, this could happen if your account requires a CAPTCHA, please try logging in inside a browser to see if this is the case, if so, you may try logging in here again later after CAPTCHA is no longer needed.''';
|
||||
}
|
||||
|
||||
abstract class RegExpConstants {
|
||||
static const String linkSuffix = r'(\)|]|,|\*)(.)*$';
|
||||
static const String number = '[0-9]+';
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
|
||||
/// Custom router.
|
||||
@ -39,8 +40,8 @@ class CustomRouter {
|
||||
appBar: AppBar(
|
||||
title: const Text('Error'),
|
||||
),
|
||||
body: const Center(
|
||||
child: Text('Something went wrong!'),
|
||||
body: Center(
|
||||
child: Text(Constants.errorMessage),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
41
lib/config/file_output.dart
Normal file
@ -0,0 +1,41 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// Writes the log output to a file.
|
||||
/// Temporary solution to not being able to access
|
||||
// ignore: comment_references
|
||||
/// the original [FileOutput] from [Logger]
|
||||
class CustomFileOutput extends LogOutput {
|
||||
CustomFileOutput({
|
||||
required this.file,
|
||||
this.overrideExisting = false,
|
||||
this.encoding = utf8,
|
||||
});
|
||||
|
||||
final File file;
|
||||
final bool overrideExisting;
|
||||
final Encoding encoding;
|
||||
IOSink? _sink;
|
||||
|
||||
@override
|
||||
void init() {
|
||||
_sink = file.openWrite(
|
||||
mode: overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend,
|
||||
encoding: encoding,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void output(OutputEvent event) {
|
||||
_sink?.writeAll(event.lines, '\n');
|
||||
_sink?.writeln();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> destroy() async {
|
||||
await _sink?.flush();
|
||||
await _sink?.close();
|
||||
}
|
||||
}
|
@ -1,8 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hacki/config/custom_log_filter.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// Global [GetIt.instance].
|
||||
@ -10,8 +13,16 @@ final GetIt locator = GetIt.instance;
|
||||
|
||||
/// Set up [GetIt] locator.
|
||||
Future<void> setUpLocator() async {
|
||||
final File logOutputFile = await LogUtil.initLogFile();
|
||||
|
||||
locator
|
||||
..registerSingleton<Logger>(Logger(filter: CustomLogFilter()))
|
||||
..registerSingleton<Logger>(
|
||||
Logger(
|
||||
filter: CustomLogFilter(),
|
||||
printer: LogUtil.logPrinter,
|
||||
output: LogUtil.logOutput(logOutputFile),
|
||||
),
|
||||
)
|
||||
..registerSingleton<StoriesRepository>(StoriesRepository())
|
||||
..registerSingleton<PreferenceRepository>(PreferenceRepository())
|
||||
..registerSingleton<SearchRepository>(SearchRepository())
|
||||
|
@ -3,6 +3,7 @@ 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';
|
||||
@ -10,13 +11,16 @@ 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() {
|
||||
@ -43,12 +47,21 @@ class CollapseCubit extends Cubit<CollapseState> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final int count = _collapseCache.collapse(_commentId);
|
||||
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(
|
||||
collapsed: true,
|
||||
collapsedCount: state.collapsed ? 0 : count,
|
||||
collapsedCount: state.collapsed ? 0 : collapsedCommentIds.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -3,31 +3,37 @@ import 'dart:async';
|
||||
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:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
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:linkify/linkify.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'comments_state.dart';
|
||||
|
||||
class CommentsCubit extends Cubit<CommentsState> {
|
||||
CommentsCubit({
|
||||
required FilterCubit filterCubit,
|
||||
required CollapseCache collapseCache,
|
||||
CommentCache? commentCache,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
Logger? logger,
|
||||
required bool offlineReading,
|
||||
required bool isOfflineReading,
|
||||
required Item item,
|
||||
required FetchMode defaultFetchMode,
|
||||
required CommentsOrder defaultCommentsOrder,
|
||||
}) : _collapseCache = collapseCache,
|
||||
}) : _filterCubit = filterCubit,
|
||||
_collapseCache = collapseCache,
|
||||
_commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
@ -38,13 +44,14 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(
|
||||
CommentsState.init(
|
||||
offlineReading: offlineReading,
|
||||
isOfflineReading: isOfflineReading,
|
||||
item: item,
|
||||
fetchMode: defaultFetchMode,
|
||||
order: defaultCommentsOrder,
|
||||
),
|
||||
);
|
||||
|
||||
final FilterCubit _filterCubit;
|
||||
final CollapseCache _collapseCache;
|
||||
final CommentCache _commentCache;
|
||||
final OfflineRepository _offlineRepository;
|
||||
@ -73,22 +80,24 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
Future<void> init({
|
||||
bool onlyShowTargetComment = false,
|
||||
bool useCommentCache = false,
|
||||
List<Comment>? targetParents,
|
||||
List<Comment>? targetAncestors,
|
||||
}) async {
|
||||
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) {
|
||||
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
comments: targetParents,
|
||||
comments: targetAncestors,
|
||||
onlyShowTargetComment: true,
|
||||
status: CommentsStatus.loaded,
|
||||
status: CommentsStatus.allLoaded,
|
||||
),
|
||||
);
|
||||
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: targetParents!.last.kids,
|
||||
level: targetParents.last.level + 1,
|
||||
ids: targetAncestors!.last.kids,
|
||||
level: targetAncestors.last.level + 1,
|
||||
)
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
|
||||
@ -104,58 +113,58 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
);
|
||||
|
||||
final Item item = state.item;
|
||||
final Item updatedItem = state.offlineReading
|
||||
final Item updatedItem = state.isOfflineReading
|
||||
? item
|
||||
: await _storiesRepository.fetchItemBy(id: item.id) ?? item;
|
||||
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
|
||||
item;
|
||||
final List<int> kids = sortKids(updatedItem.kids);
|
||||
|
||||
emit(state.copyWith(item: updatedItem));
|
||||
|
||||
if (state.offlineReading) {
|
||||
_streamSubscription = _offlineRepository
|
||||
.getCachedCommentsStream(ids: kids)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
late final Stream<Comment> commentStream;
|
||||
|
||||
if (state.isOfflineReading) {
|
||||
commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
|
||||
} else {
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
break;
|
||||
case FetchMode.eager:
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_streamSubscription = commentStream
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (state.offlineReading) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CommentsStatus.loaded,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CommentsStatus.loading,
|
||||
),
|
||||
);
|
||||
|
||||
if (state.isOfflineReading) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CommentsStatus.allLoaded,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_collapseCache.resetCollapsedComments();
|
||||
|
||||
await _streamSubscription?.cancel();
|
||||
@ -173,29 +182,29 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
final Item item = state.item;
|
||||
final Item updatedItem =
|
||||
await _storiesRepository.fetchItemBy(id: item.id) ?? item;
|
||||
await _storiesRepository.fetchItem(id: item.id) ?? item;
|
||||
final List<int> kids = sortKids(updatedItem.kids);
|
||||
|
||||
late final Stream<Comment> commentStream;
|
||||
if (state.fetchMode == FetchMode.lazy) {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(
|
||||
ids: kids,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
);
|
||||
} else {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
);
|
||||
}
|
||||
|
||||
_streamSubscription = commentStream
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
item: updatedItem,
|
||||
status: CommentsStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -213,6 +222,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
/// [comment] is only used for lazy fetching.
|
||||
void loadMore({Comment? comment}) {
|
||||
if (comment == null && state.status == CommentsStatus.loading) return;
|
||||
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
if (comment == null) return;
|
||||
@ -226,23 +237,18 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final StreamSubscription<Comment> streamSubscription =
|
||||
_storiesRepository
|
||||
.fetchCommentsStream(ids: comment.kids)
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen((Comment cmt) {
|
||||
_collapseCache.addKid(cmt.id, to: cmt.parent);
|
||||
_commentCache.cacheComment(cmt);
|
||||
_sembastRepository.cacheComment(cmt);
|
||||
|
||||
final List<LinkifyElement> elements = _linkify(
|
||||
cmt.text,
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(cmt, elements: elements);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
comments: <Comment>[...state.comments]..insert(
|
||||
state.comments.indexOf(comment) + offset + 1,
|
||||
buildableComment.copyWith(level: level),
|
||||
cmt.copyWith(level: level),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -272,8 +278,9 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
Future<void> loadParentThread() async {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
|
||||
final Story? parent =
|
||||
await _storiesRepository.fetchParentStory(id: state.item.id);
|
||||
final Story? parent = await _storiesRepository
|
||||
.fetchParentStory(id: state.item.id)
|
||||
.then(_toBuildableStory);
|
||||
|
||||
if (parent == null) {
|
||||
return;
|
||||
@ -339,22 +346,18 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onCommentFetched(Comment? comment) {
|
||||
void _onCommentFetched(BuildableComment? comment) {
|
||||
if (comment != null) {
|
||||
_collapseCache.addKid(comment.id, to: comment.parent);
|
||||
_commentCache.cacheComment(comment);
|
||||
_sembastRepository.cacheComment(comment);
|
||||
|
||||
final List<LinkifyElement> elements = _linkify(
|
||||
comment.text,
|
||||
final bool hidden = _filterCubit.state.keywords.any(
|
||||
(String keyword) => comment.text.toLowerCase().contains(keyword),
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(comment, elements: elements);
|
||||
|
||||
final List<Comment> updatedComments = <Comment>[
|
||||
...state.comments,
|
||||
buildableComment
|
||||
comment.copyWith(hidden: hidden),
|
||||
];
|
||||
|
||||
emit(state.copyWith(comments: updatedComments));
|
||||
@ -368,12 +371,17 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
if (!isHidden) {
|
||||
_streamSubscription?.pause();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CommentsStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentPage: state.currentPage + 1,
|
||||
status: CommentsStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -381,29 +389,51 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
}
|
||||
}
|
||||
|
||||
static List<LinkifyElement> _linkify(
|
||||
String text, {
|
||||
LinkifyOptions options = const LinkifyOptions(),
|
||||
List<Linkifier> linkifiers = const <Linkifier>[
|
||||
UrlLinkifier(),
|
||||
EmailLinkifier(),
|
||||
],
|
||||
}) {
|
||||
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
|
||||
static Future<Item?> _toBuildable(Item? item) async {
|
||||
if (item == null) return null;
|
||||
|
||||
if (text.isEmpty) {
|
||||
return <LinkifyElement>[];
|
||||
switch (item.runtimeType) {
|
||||
case Comment:
|
||||
return _toBuildableComment(item as Comment);
|
||||
case Story:
|
||||
return _toBuildableStory(item as Story);
|
||||
}
|
||||
|
||||
if (linkifiers.isEmpty) {
|
||||
return list;
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
|
||||
if (comment == null) return null;
|
||||
|
||||
final List<LinkifyElement> elements =
|
||||
await compute<String, List<LinkifyElement>>(
|
||||
LinkifierUtil.linkify,
|
||||
comment.text,
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(comment, elements: elements);
|
||||
|
||||
return buildableComment;
|
||||
}
|
||||
|
||||
static Future<BuildableStory?> _toBuildableStory(Story? story) async {
|
||||
if (story == null) {
|
||||
return null;
|
||||
} else if (story.text.isEmpty) {
|
||||
return BuildableStory.fromTitleOnlyStory(story);
|
||||
}
|
||||
|
||||
for (final Linkifier linkifier in linkifiers) {
|
||||
list = linkifier.parse(list, options);
|
||||
}
|
||||
final List<LinkifyElement> elements =
|
||||
await compute<String, List<LinkifyElement>>(
|
||||
LinkifierUtil.linkify,
|
||||
story.text,
|
||||
);
|
||||
|
||||
return list;
|
||||
final BuildableStory buildableStory =
|
||||
BuildableStory.fromStory(story, elements: elements);
|
||||
|
||||
return buildableStory;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -8,17 +8,6 @@ enum CommentsStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
enum CommentsOrder {
|
||||
natural,
|
||||
newestFirst,
|
||||
oldestFirst,
|
||||
}
|
||||
|
||||
enum FetchMode {
|
||||
lazy,
|
||||
eager,
|
||||
}
|
||||
|
||||
class CommentsState extends Equatable {
|
||||
const CommentsState({
|
||||
required this.item,
|
||||
@ -28,12 +17,12 @@ class CommentsState extends Equatable {
|
||||
required this.order,
|
||||
required this.fetchMode,
|
||||
required this.onlyShowTargetComment,
|
||||
required this.offlineReading,
|
||||
required this.isOfflineReading,
|
||||
required this.currentPage,
|
||||
});
|
||||
|
||||
CommentsState.init({
|
||||
required this.offlineReading,
|
||||
required this.isOfflineReading,
|
||||
required this.item,
|
||||
required this.fetchMode,
|
||||
required this.order,
|
||||
@ -50,7 +39,7 @@ class CommentsState extends Equatable {
|
||||
final CommentsOrder order;
|
||||
final FetchMode fetchMode;
|
||||
final bool onlyShowTargetComment;
|
||||
final bool offlineReading;
|
||||
final bool isOfflineReading;
|
||||
final int currentPage;
|
||||
|
||||
CommentsState copyWith({
|
||||
@ -61,7 +50,7 @@ class CommentsState extends Equatable {
|
||||
CommentsOrder? order,
|
||||
FetchMode? fetchMode,
|
||||
bool? onlyShowTargetComment,
|
||||
bool? offlineReading,
|
||||
bool? isOfflineReading,
|
||||
int? currentPage,
|
||||
}) {
|
||||
return CommentsState(
|
||||
@ -73,7 +62,7 @@ class CommentsState extends Equatable {
|
||||
fetchMode: fetchMode ?? this.fetchMode,
|
||||
onlyShowTargetComment:
|
||||
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
||||
offlineReading: offlineReading ?? this.offlineReading,
|
||||
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
);
|
||||
}
|
||||
@ -83,13 +72,13 @@ class CommentsState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
item,
|
||||
comments,
|
||||
status,
|
||||
fetchParentStatus,
|
||||
order,
|
||||
fetchMode,
|
||||
onlyShowTargetComment,
|
||||
offlineReading,
|
||||
isOfflineReading,
|
||||
currentPage,
|
||||
comments,
|
||||
];
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ export 'collapse/collapse_cubit.dart';
|
||||
export 'comments/comments_cubit.dart';
|
||||
export 'edit/edit_cubit.dart';
|
||||
export 'fav/fav_cubit.dart';
|
||||
export 'filter/filter_cubit.dart';
|
||||
export 'history/history_cubit.dart';
|
||||
export 'notification/notification_cubit.dart';
|
||||
export 'pin/pin_cubit.dart';
|
||||
@ -13,6 +14,7 @@ export 'reminder/reminder_cubit.dart';
|
||||
export 'search/search_cubit.dart';
|
||||
export 'split_view/split_view_cubit.dart';
|
||||
export 'submit/submit_cubit.dart';
|
||||
export 'tab/tab_cubit.dart';
|
||||
export 'time_machine/time_machine_cubit.dart';
|
||||
export 'user/user_cubit.dart';
|
||||
export 'vote/vote_cubit.dart';
|
||||
|
@ -73,7 +73,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
),
|
||||
);
|
||||
|
||||
final Item? item = await _storiesRepository.fetchItemBy(id: id);
|
||||
final Item? item = await _storiesRepository.fetchItem(id: id);
|
||||
|
||||
if (item == null) return;
|
||||
|
||||
@ -160,6 +160,13 @@ class FavCubit extends Cubit<FavState> {
|
||||
});
|
||||
}
|
||||
|
||||
void removeAll() {
|
||||
_preferenceRepository
|
||||
..clearAllFavs(username: '')
|
||||
..clearAllFavs(username: _authBloc.state.username);
|
||||
emit(FavState.init());
|
||||
}
|
||||
|
||||
void _onItemLoaded(Item item) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
|
@ -42,9 +42,9 @@ class FavState extends Equatable {
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
favIds,
|
||||
favItems,
|
||||
status,
|
||||
currentPage,
|
||||
favIds,
|
||||
favItems,
|
||||
];
|
||||
}
|
||||
|
40
lib/cubits/filter/filter_cubit.dart
Normal file
@ -0,0 +1,40 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
|
||||
part 'filter_state.dart';
|
||||
|
||||
class FilterCubit extends Cubit<FilterState> {
|
||||
FilterCubit({PreferenceRepository? preferenceRepository})
|
||||
: _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
super(FilterState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
|
||||
void init() {
|
||||
_preferenceRepository.filterKeywords.then(
|
||||
(List<String> keywords) => emit(
|
||||
state.copyWith(
|
||||
keywords: keywords.toSet(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void addKeyword(String keyword) {
|
||||
final Set<String> updated = Set<String>.from(state.keywords)..add(keyword);
|
||||
emit(state.copyWith(keywords: updated));
|
||||
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
|
||||
}
|
||||
|
||||
void removeKeyword(String keyword) {
|
||||
final Set<String> updated = Set<String>.from(state.keywords)
|
||||
..remove(keyword);
|
||||
emit(state.copyWith(keywords: updated));
|
||||
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
|
||||
}
|
||||
}
|
20
lib/cubits/filter/filter_state.dart
Normal file
@ -0,0 +1,20 @@
|
||||
part of 'filter_cubit.dart';
|
||||
|
||||
class FilterState extends Equatable {
|
||||
const FilterState({
|
||||
required this.keywords,
|
||||
});
|
||||
|
||||
FilterState.init() : keywords = <String>{};
|
||||
|
||||
final Set<String> keywords;
|
||||
|
||||
FilterState copyWith({Set<String>? keywords}) {
|
||||
return FilterState(
|
||||
keywords: keywords ?? this.keywords,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[keywords];
|
||||
}
|
@ -28,7 +28,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
final String username = authState.username;
|
||||
|
||||
_storiesRepository
|
||||
.fetchSubmitted(of: username)
|
||||
.fetchSubmitted(userId: username)
|
||||
.then((List<int>? submittedIds) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -94,7 +94,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
);
|
||||
|
||||
_storiesRepository
|
||||
.fetchSubmitted(of: username)
|
||||
.fetchSubmitted(userId: username)
|
||||
.then((List<int>? submittedIds) {
|
||||
emit(state.copyWith(submittedIds: submittedIds));
|
||||
if (submittedIds != null) {
|
||||
|
@ -42,9 +42,9 @@ class HistoryState extends Equatable {
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
submittedIds,
|
||||
submittedItems,
|
||||
status,
|
||||
currentPage,
|
||||
submittedIds,
|
||||
submittedItems,
|
||||
];
|
||||
}
|
||||
|
@ -30,20 +30,16 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
_authBloc.stream.listen((AuthState authState) {
|
||||
if (authState.isLoggedIn && authState.username != _username) {
|
||||
// Get the user setting.
|
||||
_preferenceRepository.shouldShowNotification
|
||||
.then((bool showNotification) {
|
||||
if (showNotification) {
|
||||
// Delaying the initialization to prevent janks in home screen.
|
||||
Future<void>.delayed(const Duration(seconds: 2), init);
|
||||
}
|
||||
});
|
||||
if (_preferenceCubit.state.notificationEnabled) {
|
||||
Future<void>.delayed(const Duration(seconds: 2), init);
|
||||
}
|
||||
|
||||
// Listen for setting changes in the future.
|
||||
_preferenceCubit.stream.listen((PreferenceState prefState) {
|
||||
final bool isActive = _timer?.isActive ?? false;
|
||||
if (prefState.showNotification && !isActive) {
|
||||
if (prefState.notificationEnabled && !isActive) {
|
||||
init();
|
||||
} else if (!prefState.showNotification) {
|
||||
} else if (!prefState.notificationEnabled) {
|
||||
_timer?.cancel();
|
||||
}
|
||||
});
|
||||
@ -85,7 +81,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
for (final int id in commentsToBeLoaded) {
|
||||
Comment? comment = await _sembastRepository.getComment(id: id);
|
||||
comment ??= await _storiesRepository.fetchCommentBy(id: id);
|
||||
comment ??= await _storiesRepository.fetchComment(id: id);
|
||||
if (comment != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -130,7 +126,8 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (_authBloc.state.isLoggedIn && _preferenceCubit.state.showNotification) {
|
||||
if (_authBloc.state.isLoggedIn &&
|
||||
_preferenceCubit.state.notificationEnabled) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: NotificationStatus.loading,
|
||||
@ -162,7 +159,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
for (final int id in commentsToBeLoaded) {
|
||||
Comment? comment = await _sembastRepository.getComment(id: id);
|
||||
comment ??= await _storiesRepository.fetchCommentBy(id: id);
|
||||
comment ??= await _storiesRepository.fetchComment(id: id);
|
||||
if (comment != null) {
|
||||
emit(state.copyWith(comments: <Comment>[...state.comments, comment]));
|
||||
}
|
||||
@ -187,7 +184,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
Future<void> _fetchReplies() {
|
||||
return _storiesRepository
|
||||
.fetchSubmitted(of: _authBloc.state.username)
|
||||
.fetchSubmitted(userId: _authBloc.state.username)
|
||||
.then((List<int>? submittedItems) async {
|
||||
if (submittedItems != null) {
|
||||
final List<int> subscribedItems = submittedItems.sublist(
|
||||
@ -196,7 +193,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
);
|
||||
|
||||
for (final int id in subscribedItems) {
|
||||
await _storiesRepository.fetchItemBy(id: id).then((Item? item) async {
|
||||
await _storiesRepository.fetchItem(id: id).then((Item? item) async {
|
||||
final List<int> kids = item?.kids ?? <int>[];
|
||||
final List<int> previousKids =
|
||||
(await _sembastRepository.kids(of: id)) ?? <int>[];
|
||||
@ -219,7 +216,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
|
||||
);
|
||||
await _storiesRepository
|
||||
.fetchCommentBy(id: newCommentId)
|
||||
.fetchComment(id: newCommentId)
|
||||
.then((Comment? comment) {
|
||||
if (comment != null && !comment.dead && !comment.deleted) {
|
||||
_sembastRepository
|
||||
|
@ -77,11 +77,11 @@ class NotificationState extends Equatable {
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
comments,
|
||||
unreadCommentsIds,
|
||||
allCommentsIds,
|
||||
currentPage,
|
||||
offset,
|
||||
status,
|
||||
comments,
|
||||
unreadCommentsIds,
|
||||
allCommentsIds,
|
||||
];
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ class PollCubit extends Cubit<PollState> {
|
||||
|
||||
if (pollOptionsIds.isEmpty || refresh) {
|
||||
final Story? updatedStory =
|
||||
await _storiesRepository.fetchStoryBy(_story.id);
|
||||
await _storiesRepository.fetchStory(id: _story.id);
|
||||
|
||||
if (updatedStory != null) {
|
||||
pollOptionsIds = updatedStory.parts;
|
||||
|
@ -1,97 +1,78 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/comments/comments_cubit.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
part 'preference_state.dart';
|
||||
|
||||
class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
PreferenceCubit({PreferenceRepository? storageRepository})
|
||||
: _preferenceRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>(),
|
||||
super(const PreferenceState.init()) {
|
||||
PreferenceCubit({
|
||||
PreferenceRepository? preferenceRepository,
|
||||
Logger? logger,
|
||||
}) : _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(PreferenceState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final Logger _logger;
|
||||
|
||||
void init() {
|
||||
_preferenceRepository.shouldShowNotification
|
||||
.then((bool value) => emit(state.copyWith(showNotification: value)));
|
||||
_preferenceRepository.shouldShowComplexStoryTile.then(
|
||||
(bool value) => emit(state.copyWith(showComplexStoryTile: value)),
|
||||
);
|
||||
_preferenceRepository.shouldShowWebFirst
|
||||
.then((bool value) => emit(state.copyWith(showWebFirst: value)));
|
||||
_preferenceRepository.shouldShowEyeCandy
|
||||
.then((bool value) => emit(state.copyWith(showEyeCandy: value)));
|
||||
_preferenceRepository.trueDarkMode
|
||||
.then((bool value) => emit(state.copyWith(useTrueDark: value)));
|
||||
_preferenceRepository.readerMode
|
||||
.then((bool value) => emit(state.copyWith(useReader: value)));
|
||||
_preferenceRepository.markReadStories
|
||||
.then((bool value) => emit(state.copyWith(markReadStories: value)));
|
||||
_preferenceRepository.shouldShowMetadata
|
||||
.then((bool value) => emit(state.copyWith(showMetadata: value)));
|
||||
_preferenceRepository.fetchMode
|
||||
.then((FetchMode value) => emit(state.copyWith(fetchMode: value)));
|
||||
_preferenceRepository.commentsOrder
|
||||
.then((CommentsOrder value) => emit(state.copyWith(order: value)));
|
||||
for (final BooleanPreference p
|
||||
in Preference.allPreferences.whereType<BooleanPreference>()) {
|
||||
initPreference<bool>(p).then<bool?>((bool? value) {
|
||||
final Preference<dynamic> updatedPreference = p.copyWith(val: value);
|
||||
emit(state.copyWithPreference(updatedPreference));
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
for (final IntPreference p
|
||||
in Preference.allPreferences.whereType<IntPreference>()) {
|
||||
initPreference<int>(p).then<int?>((int? value) {
|
||||
final Preference<dynamic> updatedPreference = p.copyWith(val: value);
|
||||
emit(state.copyWithPreference(updatedPreference));
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void toggleNotificationMode() {
|
||||
emit(state.copyWith(showNotification: !state.showNotification));
|
||||
_preferenceRepository.toggleNotificationMode();
|
||||
Future<T?> initPreference<T>(Preference<T> preference) async {
|
||||
switch (T) {
|
||||
case int:
|
||||
final int? value = await _preferenceRepository.getInt(preference.key);
|
||||
return value as T?;
|
||||
case bool:
|
||||
final bool? value = await _preferenceRepository.getBool(preference.key);
|
||||
return value as T?;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
void toggleDisplayMode() {
|
||||
emit(state.copyWith(showComplexStoryTile: !state.showComplexStoryTile));
|
||||
_preferenceRepository.toggleDisplayMode();
|
||||
}
|
||||
void update<T>(Preference<T> preference, {required T to}) {
|
||||
final T value = to;
|
||||
final Preference<T> updatedPreference = preference.copyWith(val: value);
|
||||
|
||||
void toggleNavigationMode() {
|
||||
emit(state.copyWith(showWebFirst: !state.showWebFirst));
|
||||
_preferenceRepository.toggleNavigationMode();
|
||||
}
|
||||
_logger.i('updating $preference to $value');
|
||||
|
||||
void toggleEyeCandyMode() {
|
||||
emit(state.copyWith(showEyeCandy: !state.showEyeCandy));
|
||||
_preferenceRepository.toggleEyeCandyMode();
|
||||
}
|
||||
emit(state.copyWithPreference(updatedPreference));
|
||||
|
||||
void toggleTrueDarkMode() {
|
||||
emit(state.copyWith(useTrueDark: !state.useTrueDark));
|
||||
_preferenceRepository.toggleTrueDarkMode();
|
||||
}
|
||||
|
||||
void toggleReaderMode() {
|
||||
emit(state.copyWith(useReader: !state.useReader));
|
||||
_preferenceRepository.toggleReaderMode();
|
||||
}
|
||||
|
||||
void toggleMarkReadStoriesMode() {
|
||||
emit(state.copyWith(markReadStories: !state.markReadStories));
|
||||
_preferenceRepository.toggleMarkReadStoriesMode();
|
||||
}
|
||||
|
||||
void toggleMetadataMode() {
|
||||
emit(state.copyWith(showMetadata: !state.showMetadata));
|
||||
_preferenceRepository.toggleMetadataMode();
|
||||
}
|
||||
|
||||
void selectFetchMode(FetchMode? fetchMode) {
|
||||
if (fetchMode == null || state.fetchMode == fetchMode) return;
|
||||
HapticFeedback.lightImpact();
|
||||
emit(state.copyWith(fetchMode: fetchMode));
|
||||
_preferenceRepository.selectFetchMode(fetchMode);
|
||||
}
|
||||
|
||||
void selectCommentsOrder(CommentsOrder? order) {
|
||||
if (order == null || state.order == order) return;
|
||||
HapticFeedback.lightImpact();
|
||||
emit(state.copyWith(order: order));
|
||||
_preferenceRepository.selectCommentsOrder(order);
|
||||
switch (T) {
|
||||
case int:
|
||||
_preferenceRepository.setInt(preference.key, value as int);
|
||||
break;
|
||||
case bool:
|
||||
_preferenceRepository.setBool(preference.key, value as bool);
|
||||
break;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,78 +2,103 @@ part of 'preference_cubit.dart';
|
||||
|
||||
class PreferenceState extends Equatable {
|
||||
const PreferenceState({
|
||||
required this.showNotification,
|
||||
required this.showComplexStoryTile,
|
||||
required this.showWebFirst,
|
||||
required this.showEyeCandy,
|
||||
required this.useTrueDark,
|
||||
required this.useReader,
|
||||
required this.markReadStories,
|
||||
required this.showMetadata,
|
||||
required this.fetchMode,
|
||||
required this.order,
|
||||
required this.preferences,
|
||||
});
|
||||
|
||||
const PreferenceState.init()
|
||||
: showNotification = false,
|
||||
showComplexStoryTile = false,
|
||||
showWebFirst = false,
|
||||
showEyeCandy = false,
|
||||
useTrueDark = false,
|
||||
useReader = false,
|
||||
markReadStories = false,
|
||||
showMetadata = false,
|
||||
fetchMode = FetchMode.eager,
|
||||
order = CommentsOrder.natural;
|
||||
PreferenceState.init()
|
||||
: preferences = <Preference<dynamic>>{...Preference.allPreferences};
|
||||
|
||||
final bool showNotification;
|
||||
final bool showComplexStoryTile;
|
||||
final bool showWebFirst;
|
||||
final bool showEyeCandy;
|
||||
final bool useTrueDark;
|
||||
final bool useReader;
|
||||
final bool markReadStories;
|
||||
final bool showMetadata;
|
||||
final FetchMode fetchMode;
|
||||
final CommentsOrder order;
|
||||
final Set<Preference<dynamic>> preferences;
|
||||
|
||||
PreferenceState copyWith({
|
||||
bool? showNotification,
|
||||
bool? showComplexStoryTile,
|
||||
bool? showWebFirst,
|
||||
bool? showEyeCandy,
|
||||
bool? useTrueDark,
|
||||
bool? useReader,
|
||||
bool? markReadStories,
|
||||
bool? showMetadata,
|
||||
FetchMode? fetchMode,
|
||||
CommentsOrder? order,
|
||||
Set<Preference<dynamic>>? preferences,
|
||||
}) {
|
||||
return PreferenceState(
|
||||
showNotification: showNotification ?? this.showNotification,
|
||||
showComplexStoryTile: showComplexStoryTile ?? this.showComplexStoryTile,
|
||||
showWebFirst: showWebFirst ?? this.showWebFirst,
|
||||
showEyeCandy: showEyeCandy ?? this.showEyeCandy,
|
||||
useTrueDark: useTrueDark ?? this.useTrueDark,
|
||||
useReader: useReader ?? this.useReader,
|
||||
markReadStories: markReadStories ?? this.markReadStories,
|
||||
showMetadata: showMetadata ?? this.showMetadata,
|
||||
fetchMode: fetchMode ?? this.fetchMode,
|
||||
order: order ?? this.order,
|
||||
preferences: preferences ?? this.preferences,
|
||||
);
|
||||
}
|
||||
|
||||
PreferenceState copyWithPreference<T extends Preference<dynamic>>(
|
||||
T preference,
|
||||
) {
|
||||
return PreferenceState(
|
||||
preferences: <Preference<dynamic>>{
|
||||
...preferences.toList()
|
||||
..remove(preference)
|
||||
..insert(Preference.allPreferences.indexOf(preference), preference),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool isOn<T extends BooleanPreference>(T preference) {
|
||||
return preferences
|
||||
.whereType<BooleanPreference>()
|
||||
.singleWhere(
|
||||
(BooleanPreference e) => e.runtimeType == preference.runtimeType,
|
||||
)
|
||||
.val;
|
||||
}
|
||||
|
||||
bool _isOn<T extends BooleanPreference>() {
|
||||
return preferences
|
||||
.whereType<BooleanPreference>()
|
||||
.singleWhere(
|
||||
(BooleanPreference e) => e.runtimeType == T,
|
||||
)
|
||||
.val;
|
||||
}
|
||||
|
||||
bool get notificationEnabled => _isOn<NotificationModePreference>();
|
||||
|
||||
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
|
||||
|
||||
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
|
||||
|
||||
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();
|
||||
|
||||
bool get readerEnabled => _isOn<ReaderModePreference>();
|
||||
|
||||
bool get markReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
|
||||
|
||||
bool get metadataEnabled => _isOn<MetadataModePreference>();
|
||||
|
||||
bool get urlEnabled => _isOn<StoryUrlModePreference>();
|
||||
|
||||
bool get tapAnywhereToCollapseEnabled => _isOn<CollapseModePreference>();
|
||||
|
||||
bool get swipeGestureEnabled => _isOn<SwipeGesturePreference>();
|
||||
|
||||
List<StoryType> get tabs {
|
||||
final String result =
|
||||
preferences.singleWhereType<TabOrderPreference>().val.toString();
|
||||
final List<int> tabIndexes = List<int>.generate(
|
||||
result.length,
|
||||
(int index) => result.codeUnitAt(index) - 48,
|
||||
);
|
||||
final List<StoryType> tabs = tabIndexes
|
||||
.map((int index) => StoryType.values.elementAt(index))
|
||||
.toList();
|
||||
|
||||
if (tabs.length < StoryType.values.length) {
|
||||
tabs.insert(0, StoryType.values.first);
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
FetchMode get fetchMode => FetchMode.values
|
||||
.elementAt(preferences.singleWhereType<FetchModePreference>().val);
|
||||
|
||||
CommentsOrder get order => CommentsOrder.values
|
||||
.elementAt(preferences.singleWhereType<CommentsOrderPreference>().val);
|
||||
|
||||
FontSize get fontSize => FontSize.values
|
||||
.elementAt(preferences.singleWhereType<FontSizePreference>().val);
|
||||
|
||||
Font get font =>
|
||||
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
showNotification,
|
||||
showComplexStoryTile,
|
||||
showWebFirst,
|
||||
showEyeCandy,
|
||||
useTrueDark,
|
||||
useReader,
|
||||
markReadStories,
|
||||
showMetadata,
|
||||
fetchMode,
|
||||
order,
|
||||
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),
|
||||
];
|
||||
}
|
||||
|
@ -15,87 +15,131 @@ class SearchCubit extends Cubit<SearchState> {
|
||||
|
||||
final SearchRepository _searchRepository;
|
||||
|
||||
StreamSubscription<Story>? streamSubscription;
|
||||
StreamSubscription<Item>? streamSubscription;
|
||||
|
||||
void search(String query) {
|
||||
streamSubscription?.cancel();
|
||||
emit(
|
||||
state.copyWith(
|
||||
results: <Story>[],
|
||||
results: <Item>[],
|
||||
status: SearchStatus.loading,
|
||||
searchFilters: state.searchFilters.copyWith(query: query, page: 0),
|
||||
params: state.params.copyWith(query: query, page: 0),
|
||||
),
|
||||
);
|
||||
streamSubscription = _searchRepository
|
||||
.search(filters: state.searchFilters)
|
||||
.listen(_onStoryFetched)
|
||||
..onDone(() {
|
||||
emit(state.copyWith(status: SearchStatus.loaded));
|
||||
});
|
||||
streamSubscription =
|
||||
_searchRepository.search(params: state.params).listen(_onItemFetched)
|
||||
..onDone(() {
|
||||
emit(state.copyWith(status: SearchStatus.loaded));
|
||||
});
|
||||
}
|
||||
|
||||
void loadMore() {
|
||||
if (state.status != SearchStatus.loading) {
|
||||
final int updatedPage = state.searchFilters.page + 1;
|
||||
final int updatedPage = state.params.page + 1;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: SearchStatus.loadingMore,
|
||||
searchFilters: state.searchFilters.copyWith(page: updatedPage),
|
||||
params: state.params.copyWith(page: updatedPage),
|
||||
),
|
||||
);
|
||||
streamSubscription = _searchRepository
|
||||
.search(filters: state.searchFilters)
|
||||
.listen(_onStoryFetched)
|
||||
..onDone(() {
|
||||
emit(state.copyWith(status: SearchStatus.loaded));
|
||||
});
|
||||
streamSubscription =
|
||||
_searchRepository.search(params: state.params).listen(_onItemFetched)
|
||||
..onDone(() {
|
||||
emit(state.copyWith(status: SearchStatus.loaded));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void addFilter<T extends SearchFilter>(T filter) {
|
||||
if (state.searchFilters.contains<T>()) {
|
||||
if (state.params.contains<T>()) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchFilters: state.searchFilters.copyWithFilterRemoved<T>(),
|
||||
params: state.params.copyWithFilterRemoved<T>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchFilters: state.searchFilters.copyWithFilterAdded(filter),
|
||||
params: state.params.copyWithFilterAdded(filter),
|
||||
),
|
||||
);
|
||||
|
||||
search(state.searchFilters.query);
|
||||
search(state.params.query);
|
||||
}
|
||||
|
||||
void removeFilter<T extends SearchFilter>() {
|
||||
if (state.params.contains<T>() == false) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchFilters: state.searchFilters.copyWithFilterRemoved<T>(),
|
||||
params: state.params.copyWithFilterRemoved<T>(),
|
||||
),
|
||||
);
|
||||
|
||||
search(state.searchFilters.query);
|
||||
search(state.params.query);
|
||||
}
|
||||
|
||||
void onToggled(TypeTagFilter filter) {
|
||||
if (state.params.contains<TypeTagFilter>() &&
|
||||
state.params.get<TypeTagFilter>() == filter) {
|
||||
removeFilter<TypeTagFilter>();
|
||||
} else {
|
||||
removeFilter<TypeTagFilter>();
|
||||
addFilter<TypeTagFilter>(filter);
|
||||
}
|
||||
}
|
||||
|
||||
void onSortToggled() {
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchFilters: state.searchFilters.copyWith(
|
||||
sorted: !state.searchFilters.sorted,
|
||||
params: state.params.copyWith(
|
||||
sorted: !state.params.sorted,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
search(state.searchFilters.query);
|
||||
search(state.params.query);
|
||||
}
|
||||
|
||||
void _onStoryFetched(Story story) {
|
||||
void onDateTimeRangeUpdated(DateTime start, DateTime end) {
|
||||
final DateTime updatedStart = start.copyWith(
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
final DateTime updatedEnd = end.copyWith(
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
final DateTime? existingStart =
|
||||
state.params.get<DateTimeRangeFilter>()?.startTime;
|
||||
final DateTime? existingEnd =
|
||||
state.params.get<DateTimeRangeFilter>()?.endTime;
|
||||
|
||||
if (existingStart == updatedStart && existingEnd == updatedEnd) return;
|
||||
|
||||
addFilter(
|
||||
DateTimeRangeFilter(
|
||||
startTime: updatedStart,
|
||||
endTime: updatedEnd,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onPostedByChanged(String? username) {
|
||||
if (username == null) {
|
||||
removeFilter<PostedByFilter>();
|
||||
} else {
|
||||
addFilter(PostedByFilter(author: username));
|
||||
}
|
||||
}
|
||||
|
||||
void _onItemFetched(Item item) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
results: List<Story>.from(state.results)..add(story),
|
||||
results: List<Item>.from(state.results)..add(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -11,27 +11,27 @@ class SearchState extends Equatable {
|
||||
const SearchState({
|
||||
required this.status,
|
||||
required this.results,
|
||||
required this.searchFilters,
|
||||
required this.params,
|
||||
});
|
||||
|
||||
SearchState.init()
|
||||
: status = SearchStatus.initial,
|
||||
results = <Story>[],
|
||||
searchFilters = SearchFilters.init();
|
||||
results = <Item>[],
|
||||
params = SearchParams.init();
|
||||
|
||||
final List<Story> results;
|
||||
final List<Item> results;
|
||||
final SearchStatus status;
|
||||
final SearchFilters searchFilters;
|
||||
final SearchParams params;
|
||||
|
||||
SearchState copyWith({
|
||||
List<Story>? results,
|
||||
List<Item>? results,
|
||||
SearchStatus? status,
|
||||
SearchFilters? searchFilters,
|
||||
SearchParams? params,
|
||||
}) {
|
||||
return SearchState(
|
||||
results: results ?? this.results,
|
||||
status: status ?? this.status,
|
||||
searchFilters: searchFilters ?? this.searchFilters,
|
||||
params: params ?? this.params,
|
||||
);
|
||||
}
|
||||
|
||||
@ -39,6 +39,6 @@ class SearchState extends Equatable {
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
results,
|
||||
searchFilters,
|
||||
params,
|
||||
];
|
||||
}
|
||||
|
46
lib/cubits/tab/tab_cubit.dart
Normal file
@ -0,0 +1,46 @@
|
||||
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/models/models.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
part 'tab_state.dart';
|
||||
|
||||
class TabCubit extends Cubit<TabState> {
|
||||
TabCubit({
|
||||
required PreferenceCubit preferenceCubit,
|
||||
Logger? logger,
|
||||
}) : _preferenceCubit = preferenceCubit,
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(TabState.init());
|
||||
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final Logger _logger;
|
||||
|
||||
void init() {
|
||||
final List<StoryType> tabs = _preferenceCubit.state.tabs;
|
||||
|
||||
_logger.i('updating tabs to $tabs');
|
||||
|
||||
emit(state.copyWith(tabs: tabs));
|
||||
}
|
||||
|
||||
void update(int startIndex, int endIndex) {
|
||||
_logger.d('updating ${state.tabs} by moving $startIndex to $endIndex');
|
||||
final StoryType tab = state.tabs.elementAt(startIndex);
|
||||
final List<StoryType> updatedTabs = List<StoryType>.from(state.tabs)
|
||||
..insert(endIndex, tab)
|
||||
..removeAt(startIndex < endIndex ? startIndex : startIndex + 1);
|
||||
_logger.d(updatedTabs);
|
||||
emit(state.copyWith(tabs: updatedTabs));
|
||||
|
||||
// Check to make sure there's no duplicate.
|
||||
if (updatedTabs.toSet().length == StoryType.values.length) {
|
||||
_preferenceCubit.update<int>(
|
||||
TabOrderPreference(),
|
||||
to: StoryType.convertToSettingsValue(updatedTabs),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
18
lib/cubits/tab/tab_state.dart
Normal file
@ -0,0 +1,18 @@
|
||||
part of 'tab_cubit.dart';
|
||||
|
||||
class TabState extends Equatable {
|
||||
const TabState({required this.tabs});
|
||||
|
||||
TabState.init() : tabs = <StoryType>[];
|
||||
|
||||
final List<StoryType> tabs;
|
||||
|
||||
TabState copyWith({
|
||||
List<StoryType>? tabs,
|
||||
}) {
|
||||
return TabState(tabs: tabs ?? this.tabs);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[tabs];
|
||||
}
|
@ -20,20 +20,20 @@ class TimeMachineCubit extends Cubit<TimeMachineState> {
|
||||
final CommentCache _commentCache;
|
||||
|
||||
Future<void> activateTimeMachine(Comment comment) async {
|
||||
emit(state.copyWith(parents: <Comment>[]));
|
||||
emit(state.copyWith(ancestors: <Comment>[]));
|
||||
|
||||
final List<Comment> parents = <Comment>[];
|
||||
final List<Comment> ancestors = <Comment>[];
|
||||
Comment? parent = _commentCache.getComment(comment.parent);
|
||||
parent ??= await _sembastRepository.getCachedComment(id: comment.parent);
|
||||
|
||||
while (parent != null) {
|
||||
parents.insert(0, parent);
|
||||
ancestors.insert(0, parent);
|
||||
|
||||
final int parentId = parent.parent;
|
||||
parent = _commentCache.getComment(parentId);
|
||||
parent ??= await _sembastRepository.getCachedComment(id: parentId);
|
||||
}
|
||||
|
||||
emit(state.copyWith(parents: parents));
|
||||
emit(state.copyWith(ancestors: ancestors));
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,18 @@
|
||||
part of 'time_machine_cubit.dart';
|
||||
|
||||
class TimeMachineState extends Equatable {
|
||||
const TimeMachineState({required this.parents});
|
||||
const TimeMachineState({required this.ancestors});
|
||||
|
||||
TimeMachineState.init() : parents = <Comment>[];
|
||||
TimeMachineState.init() : ancestors = <Comment>[];
|
||||
|
||||
final List<Comment> parents;
|
||||
final List<Comment> ancestors;
|
||||
|
||||
TimeMachineState copyWith({
|
||||
List<Comment>? parents,
|
||||
List<Comment>? ancestors,
|
||||
}) {
|
||||
return TimeMachineState(parents: parents ?? this.parents);
|
||||
return TimeMachineState(ancestors: ancestors ?? this.ancestors);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[parents];
|
||||
List<Object?> get props => <Object?>[ancestors];
|
||||
}
|
||||
|
@ -10,14 +10,19 @@ class UserCubit extends Cubit<UserState> {
|
||||
UserCubit({StoriesRepository? storiesRepository})
|
||||
: _storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
super(UserState.init());
|
||||
super(const UserState.init());
|
||||
|
||||
final StoriesRepository _storiesRepository;
|
||||
|
||||
void init({required String userId}) {
|
||||
emit(state.copyWith(status: UserStatus.loading));
|
||||
_storiesRepository.fetchUserBy(userId: userId).then((User user) {
|
||||
emit(state.copyWith(user: user, status: UserStatus.loaded));
|
||||
_storiesRepository.fetchUser(id: userId).then((User? user) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user ?? User.emptyWithId(userId),
|
||||
status: UserStatus.loaded,
|
||||
),
|
||||
);
|
||||
}).onError((_, __) {
|
||||
emit(state.copyWith(status: UserStatus.failure));
|
||||
return;
|
||||
|
@ -13,8 +13,8 @@ class UserState extends Equatable {
|
||||
required this.status,
|
||||
});
|
||||
|
||||
UserState.init()
|
||||
: user = User.empty(),
|
||||
const UserState.init()
|
||||
: user = const User.empty(),
|
||||
status = UserStatus.initial;
|
||||
|
||||
final User user;
|
||||
|
@ -2,6 +2,8 @@ import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
extension ContextExtension on BuildContext {
|
||||
T? tryRead<T>() {
|
||||
@ -12,6 +14,31 @@ extension ContextExtension on BuildContext {
|
||||
}
|
||||
}
|
||||
|
||||
void showSnackBar({
|
||||
required String content,
|
||||
VoidCallback? action,
|
||||
String? label,
|
||||
}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Palette.deepOrange,
|
||||
content: Text(content),
|
||||
action: action != null && label != null
|
||||
? SnackBarAction(
|
||||
label: label,
|
||||
onPressed: action,
|
||||
textColor: Theme.of(this).textTheme.bodyLarge?.color,
|
||||
)
|
||||
: null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorSnackBar() => showSnackBar(
|
||||
content: Constants.errorMessage,
|
||||
);
|
||||
|
||||
Rect? get rect {
|
||||
final RenderBox? box = findRenderObject() as RenderBox?;
|
||||
final Rect? rect =
|
||||
@ -22,12 +49,12 @@ extension ContextExtension on BuildContext {
|
||||
static double _screenWidth = 0;
|
||||
static double _storyTileHeight = 0;
|
||||
static int _storyTileMaxLines = 4;
|
||||
static const double _screenWidthLowerBound = 430,
|
||||
_screenWidthUpperBound = 850,
|
||||
_picHeightLowerBound = 110,
|
||||
_picHeightUpperBound = 128,
|
||||
_smallPicHeight = 100,
|
||||
_picHeightFactor = 0.3;
|
||||
static const double _screenWidthLowerBound = 430;
|
||||
static const double _screenWidthUpperBound = 850;
|
||||
static const double _picHeightLowerBound = 110;
|
||||
static const double _picHeightUpperBound = 128;
|
||||
static const double _smallPicHeight = 100;
|
||||
static const double _picHeightFactor = 0.3;
|
||||
|
||||
double get storyTileHeight {
|
||||
final double screenWidth =
|
||||
|
@ -1,5 +1,5 @@
|
||||
extension DateTimeExtension on DateTime {
|
||||
String toReadableString() {
|
||||
String toTimeAgoString() {
|
||||
final DateTime now = DateTime.now();
|
||||
final Duration diff = now.difference(this);
|
||||
if (diff.inDays > 365) {
|
||||
|
@ -3,6 +3,7 @@ export 'date_time_extension.dart';
|
||||
export 'int_extension.dart';
|
||||
export 'list_extension.dart';
|
||||
export 'object_extension.dart';
|
||||
export 'set_extension.dart';
|
||||
export 'state_extension.dart';
|
||||
export 'string_extension.dart';
|
||||
export 'widget_extension.dart';
|
||||
|
@ -1,7 +1,19 @@
|
||||
import 'dart:developer' as dev;
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
extension ObjectExtension on Object {
|
||||
void log({String identifier = ''}) {
|
||||
dev.log('$identifier ${toString()}', level: 2000);
|
||||
void log([String identifier = '']) {
|
||||
locator.get<Logger>().d('$identifier ${toString()}');
|
||||
}
|
||||
|
||||
void logInfo({String identifier = ''}) {
|
||||
locator.get<Logger>().i('$identifier ${toString()}');
|
||||
}
|
||||
|
||||
void logError({
|
||||
String identifier = '',
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
locator.get<Logger>().e(identifier, this, stackTrace ?? StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
13
lib/extensions/set_extension.dart
Normal file
@ -0,0 +1,13 @@
|
||||
extension SetExtension<E> on Set<E> {
|
||||
void removeWhereType<T extends E>() {
|
||||
return removeWhere((E e) => e is T);
|
||||
}
|
||||
|
||||
bool hasType<T extends E>() {
|
||||
return whereType<T>().isNotEmpty;
|
||||
}
|
||||
|
||||
T singleWhereType<T extends E>() {
|
||||
return whereType<T>().single;
|
||||
}
|
||||
}
|
@ -1,9 +1,16 @@
|
||||
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';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
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:share_plus/share_plus.dart';
|
||||
|
||||
extension StateExtension on State {
|
||||
void showSnackBar({
|
||||
@ -11,22 +18,15 @@ extension StateExtension on State {
|
||||
VoidCallback? action,
|
||||
String? label,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Palette.deepOrange,
|
||||
content: Text(content),
|
||||
action: action != null && label != null
|
||||
? SnackBarAction(
|
||||
label: label,
|
||||
onPressed: action,
|
||||
textColor: Theme.of(context).textTheme.bodyText1?.color,
|
||||
)
|
||||
: null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
context.showSnackBar(
|
||||
content: content,
|
||||
action: action,
|
||||
label: label,
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorSnackBar() => context.showErrorSnackBar();
|
||||
|
||||
Future<void>? goToItemScreen({
|
||||
required ItemScreenArgs args,
|
||||
bool forceNewScreen = false,
|
||||
@ -44,4 +44,192 @@ extension StateExtension on State {
|
||||
|
||||
return Future<void>.value();
|
||||
}
|
||||
|
||||
void onMoreTapped(Item item, Rect? rect) {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
if (item.dead || item.deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final bool isBlocked =
|
||||
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
|
||||
showModalBottomSheet<MenuAction>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext context) {
|
||||
return SafeArea(
|
||||
child: MorePopupMenu(
|
||||
item: item,
|
||||
isBlocked: isBlocked,
|
||||
onLoginTapped: onLoginTapped,
|
||||
),
|
||||
);
|
||||
},
|
||||
).then((MenuAction? action) {
|
||||
if (action != null) {
|
||||
switch (action) {
|
||||
case MenuAction.upvote:
|
||||
break;
|
||||
case MenuAction.downvote:
|
||||
break;
|
||||
case MenuAction.fav:
|
||||
onFavTapped(item);
|
||||
break;
|
||||
case MenuAction.share:
|
||||
onShareTapped(item, rect);
|
||||
break;
|
||||
case MenuAction.flag:
|
||||
onFlagTapped(item);
|
||||
break;
|
||||
case MenuAction.block:
|
||||
onBlockTapped(item, isBlocked: isBlocked);
|
||||
break;
|
||||
case MenuAction.cancel:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onFavTapped(Item item) {
|
||||
final FavCubit favCubit = context.read<FavCubit>();
|
||||
final bool isFav = favCubit.state.favIds.contains(item.id);
|
||||
if (isFav) {
|
||||
favCubit.removeFav(item.id);
|
||||
} else {
|
||||
favCubit.addFav(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onShareTapped(Item item, Rect? rect) async {
|
||||
late final String? linkToShare;
|
||||
if (item.url.isNotEmpty) {
|
||||
linkToShare = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return SafeArea(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => Navigator.pop(context, item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => Navigator.pop(
|
||||
context,
|
||||
'https://news.ycombinator.com/item?id=${item.id}',
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
|
||||
}
|
||||
|
||||
if (linkToShare != null) {
|
||||
await Share.share(
|
||||
linkToShare,
|
||||
sharePositionOrigin: rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onFlagTapped(Item item) {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Flag this comment?'),
|
||||
content: Text(
|
||||
'Flag this comment posted by ${item.by}?',
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'Yes',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((bool? yesTapped) {
|
||||
if (yesTapped ?? false) {
|
||||
context.read<AuthBloc>().add(AuthFlag(item: item));
|
||||
showSnackBar(content: 'Comment flagged!');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onBlockTapped(Item item, {required bool isBlocked}) {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('${isBlocked ? 'Unblock' : 'Block'} this user?'),
|
||||
content: Text(
|
||||
'Do you want to ${isBlocked ? 'unblock' : 'block'} ${item.by}'
|
||||
' and ${isBlocked ? 'display' : 'hide'} '
|
||||
'comments posted by this user?',
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'Yes',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((bool? yesTapped) {
|
||||
if (yesTapped ?? false) {
|
||||
if (isBlocked) {
|
||||
context.read<BlocklistCubit>().removeFromBlocklist(item.by);
|
||||
} else {
|
||||
context.read<BlocklistCubit>().addToBlocklist(item.by);
|
||||
}
|
||||
showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onLoginTapped() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return const LoginDialog();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,15 @@
|
||||
import 'package:hacki/config/constants.dart';
|
||||
|
||||
extension StringExtension on String {
|
||||
int? getItemId() {
|
||||
final RegExp regex = RegExp(r'\d+$');
|
||||
final RegExp exception = RegExp(r'\)|].*$');
|
||||
int? get itemId {
|
||||
final RegExp regex = RegExp(RegExpConstants.number);
|
||||
final RegExp exception = RegExp(RegExpConstants.linkSuffix);
|
||||
final String match = regex.stringMatch(replaceAll(exception, '')) ?? '';
|
||||
return int.tryParse(match);
|
||||
}
|
||||
|
||||
bool get isStoryLink => contains('news.ycombinator.com/item');
|
||||
|
||||
String removeAllEmojis() {
|
||||
final RegExp regex = RegExp(
|
||||
r'(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])',
|
||||
|
@ -1,4 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
extension WidgetModifier on Widget {
|
||||
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
|
||||
@ -7,4 +11,62 @@ extension WidgetModifier on Widget {
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
Widget contextMenuBuilder(
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState, {
|
||||
required Item item,
|
||||
}) {
|
||||
final int start = editableTextState.textEditingValue.selection.base.offset;
|
||||
final int end = editableTextState.textEditingValue.selection.end;
|
||||
|
||||
final List<ContextMenuButtonItem> items = <ContextMenuButtonItem>[
|
||||
...editableTextState.contextMenuButtonItems,
|
||||
];
|
||||
|
||||
if (start != -1 && end != -1) {
|
||||
String selectedText = item.text.substring(start, end);
|
||||
|
||||
if (item is Buildable) {
|
||||
final Iterable<EmphasisElement> emphasisElements =
|
||||
(item as Buildable).elements.whereType<EmphasisElement>();
|
||||
|
||||
int count = 1;
|
||||
while (selectedText.contains(' ') && count <= emphasisElements.length) {
|
||||
final int s = (start + count * 2).clamp(0, item.text.length);
|
||||
final int e = (end + count * 2).clamp(0, item.text.length);
|
||||
selectedText = item.text.substring(s, e);
|
||||
count++;
|
||||
}
|
||||
|
||||
count = 1;
|
||||
while (selectedText.contains(' ') && count <= emphasisElements.length) {
|
||||
final int s = (start - count * 2).clamp(0, item.text.length);
|
||||
final int e = (end - count * 2).clamp(0, item.text.length);
|
||||
selectedText = item.text.substring(s, e);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
items.addAll(<ContextMenuButtonItem>[
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'''${Constants.wikipediaLink}$selectedText''',
|
||||
),
|
||||
label: 'Wikipedia',
|
||||
),
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'''${Constants.wiktionaryLink}$selectedText''',
|
||||
),
|
||||
label: 'Wiktionary',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return AdaptiveTextSelectionToolbar.buttonItems(
|
||||
anchors: editableTextState.contextMenuAnchors,
|
||||
buttonItems: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,8 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -14,13 +16,15 @@ import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/custom_router.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/repositories/repositories.dart' show PreferenceRepository;
|
||||
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';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@ -36,11 +40,30 @@ final BehaviorSubject<String?> siriSuggestionSubject =
|
||||
|
||||
late final bool isTesting;
|
||||
|
||||
void notificationReceiver(NotificationResponse details) =>
|
||||
selectNotificationSubject.add(details.payload);
|
||||
|
||||
Future<void> main({bool testing = false}) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
isTesting = testing;
|
||||
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
final String tempPath = tempDir.path;
|
||||
Hive.init(tempPath);
|
||||
|
||||
await setUpLocator();
|
||||
|
||||
EquatableConfig.stringify = true;
|
||||
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
locator.get<Logger>().e(
|
||||
details.summary,
|
||||
details.exceptionAsString(),
|
||||
details.stack,
|
||||
);
|
||||
};
|
||||
|
||||
final HydratedStorage storage = await HydratedStorage.build(
|
||||
storageDirectory: kIsWeb
|
||||
? HydratedStorage.webStorageDirectory
|
||||
@ -58,8 +81,8 @@ Future<void> main({bool testing = false}) async {
|
||||
FlutterLocalNotificationsPlugin();
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const IOSInitializationSettings initializationSettingsIOS =
|
||||
IOSInitializationSettings();
|
||||
const DarwinInitializationSettings initializationSettingsIOS =
|
||||
DarwinInitializationSettings();
|
||||
const InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
@ -67,7 +90,8 @@ Future<void> main({bool testing = false}) async {
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
initializationSettings,
|
||||
onSelectNotification: selectNotificationSubject.add,
|
||||
onDidReceiveBackgroundNotificationResponse: notificationReceiver,
|
||||
onDidReceiveNotificationResponse: notificationReceiver,
|
||||
);
|
||||
await flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
@ -88,13 +112,19 @@ Future<void> main({bool testing = false}) async {
|
||||
},
|
||||
);
|
||||
} else if (Platform.isAndroid) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
|
||||
final int sdk = androidInfo.version.sdkInt;
|
||||
|
||||
if (sdk > 28) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Palette.transparent,
|
||||
systemNavigationBarColor: Palette.transparent,
|
||||
systemNavigationBarDividerColor: Palette.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.edgeToEdge,
|
||||
@ -102,24 +132,27 @@ Future<void> main({bool testing = false}) async {
|
||||
);
|
||||
}
|
||||
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
final String tempPath = tempDir.path;
|
||||
Hive.init(tempPath);
|
||||
|
||||
await setUpLocator();
|
||||
|
||||
final AdaptiveThemeMode? savedThemeMode = await AdaptiveTheme.getThemeMode();
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
final bool trueDarkMode =
|
||||
prefs.getBool(PreferenceRepository.trueDarkModeKey) ?? false;
|
||||
prefs.getBool(const TrueDarkModePreference().key) ?? false;
|
||||
final Font font = Font.values.elementAt(
|
||||
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
|
||||
);
|
||||
|
||||
// ignore: prefer_asserts_with_message
|
||||
assert(() {
|
||||
Bloc.observer = CustomBlocObserver();
|
||||
return true;
|
||||
}());
|
||||
|
||||
Bloc.observer = CustomBlocObserver();
|
||||
HydratedBloc.storage = storage;
|
||||
|
||||
runApp(
|
||||
HackiApp(
|
||||
savedThemeMode: savedThemeMode,
|
||||
trueDarkMode: trueDarkMode,
|
||||
font: font,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -129,9 +162,11 @@ class HackiApp extends StatelessWidget {
|
||||
super.key,
|
||||
this.savedThemeMode,
|
||||
required this.trueDarkMode,
|
||||
required this.font,
|
||||
});
|
||||
|
||||
final AdaptiveThemeMode? savedThemeMode;
|
||||
final Font font;
|
||||
final bool trueDarkMode;
|
||||
|
||||
static final GlobalKey<NavigatorState> navigatorKey =
|
||||
@ -145,9 +180,14 @@ class HackiApp extends StatelessWidget {
|
||||
lazy: false,
|
||||
create: (BuildContext context) => PreferenceCubit(),
|
||||
),
|
||||
BlocProvider<FilterCubit>(
|
||||
lazy: false,
|
||||
create: (BuildContext context) => FilterCubit(),
|
||||
),
|
||||
BlocProvider<StoriesBloc>(
|
||||
create: (BuildContext context) => StoriesBloc(
|
||||
preferenceCubit: context.read<PreferenceCubit>(),
|
||||
filterCubit: context.read<FilterCubit>(),
|
||||
),
|
||||
),
|
||||
BlocProvider<AuthBloc>(
|
||||
@ -201,15 +241,22 @@ class HackiApp extends StatelessWidget {
|
||||
lazy: false,
|
||||
create: (BuildContext context) => EditCubit(),
|
||||
),
|
||||
BlocProvider<TabCubit>(
|
||||
create: (BuildContext context) => TabCubit(
|
||||
preferenceCubit: context.read<PreferenceCubit>(),
|
||||
)..init(),
|
||||
)
|
||||
],
|
||||
child: AdaptiveTheme(
|
||||
light: ThemeData(
|
||||
primarySwatch: Palette.orange,
|
||||
fontFamily: font.name,
|
||||
),
|
||||
dark: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: trueDarkMode ? Palette.black : null,
|
||||
fontFamily: font.name,
|
||||
),
|
||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||
builder: (ThemeData theme, ThemeData darkTheme) {
|
||||
@ -217,6 +264,7 @@ class HackiApp extends StatelessWidget {
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: Palette.black,
|
||||
fontFamily: font.name,
|
||||
);
|
||||
return FutureBuilder<AdaptiveThemeMode?>(
|
||||
future: AdaptiveTheme.getThemeMode(),
|
||||
@ -225,12 +273,16 @@ 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) =>
|
||||
previous.useTrueDark != current.useTrueDark,
|
||||
previous.trueDarkEnabled != current.trueDarkEnabled,
|
||||
builder: (BuildContext context, PreferenceState prefState) {
|
||||
final bool useTrueDark = prefState.useTrueDark &&
|
||||
final bool useTrueDark = prefState.trueDarkEnabled &&
|
||||
(mode == AdaptiveThemeMode.dark ||
|
||||
(mode == AdaptiveThemeMode.system &&
|
||||
SchedulerBinding
|
||||
|
@ -1,100 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hacki/models/item.dart';
|
||||
|
||||
class Comment extends Item {
|
||||
Comment({
|
||||
required super.id,
|
||||
required super.time,
|
||||
required super.parent,
|
||||
required super.score,
|
||||
required super.by,
|
||||
required super.text,
|
||||
required super.kids,
|
||||
required super.dead,
|
||||
required super.deleted,
|
||||
required this.level,
|
||||
}) : super(
|
||||
descendants: 0,
|
||||
parts: <int>[],
|
||||
title: '',
|
||||
url: '',
|
||||
type: '',
|
||||
);
|
||||
|
||||
Comment.fromJson(Map<String, dynamic> json, {this.level = 0})
|
||||
: super(
|
||||
id: json['id'] as int? ?? 0,
|
||||
time: json['time'] as int? ?? 0,
|
||||
by: json['by'] as String? ?? '',
|
||||
text: json['text'] as String? ?? '',
|
||||
kids: (json['kids'] as List<dynamic>?)?.cast<int>() ?? <int>[],
|
||||
parent: json['parent'] as int? ?? 0,
|
||||
deleted: json['deleted'] as bool? ?? false,
|
||||
score: json['score'] as int? ?? 0,
|
||||
descendants: 0,
|
||||
dead: json['dead'] as bool? ?? false,
|
||||
parts: <int>[],
|
||||
title: '',
|
||||
url: '',
|
||||
type: '',
|
||||
);
|
||||
|
||||
final int level;
|
||||
|
||||
String get metadata => '''by $by $postedDate''';
|
||||
|
||||
Comment copyWith({int? level}) {
|
||||
return Comment(
|
||||
id: id,
|
||||
time: time,
|
||||
parent: parent,
|
||||
score: score,
|
||||
by: by,
|
||||
text: text,
|
||||
kids: kids,
|
||||
dead: dead,
|
||||
deleted: deleted,
|
||||
level: level ?? this.level,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'id': id,
|
||||
'time': time,
|
||||
'by': by,
|
||||
'text': text,
|
||||
'kids': kids,
|
||||
'parent': parent,
|
||||
'deleted': deleted,
|
||||
'dead': dead,
|
||||
'score': score,
|
||||
'level': level,
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final String prettyString =
|
||||
const JsonEncoder.withIndent(' ').convert(this);
|
||||
return 'Comment $prettyString';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
score,
|
||||
descendants,
|
||||
time,
|
||||
by,
|
||||
title,
|
||||
url,
|
||||
kids,
|
||||
dead,
|
||||
parts,
|
||||
deleted,
|
||||
parent,
|
||||
text,
|
||||
type,
|
||||
];
|
||||
}
|
9
lib/models/comments_order.dart
Normal file
@ -0,0 +1,9 @@
|
||||
enum CommentsOrder {
|
||||
natural('Natural'),
|
||||
newestFirst('Newest first'),
|
||||
oldestFirst('Oldest first');
|
||||
|
||||
const CommentsOrder(this.description);
|
||||
|
||||
final String description;
|
||||
}
|
9
lib/models/displayable.dart
Normal file
@ -0,0 +1,9 @@
|
||||
mixin SettingsDisplayable {
|
||||
String get title;
|
||||
|
||||
String get subtitle => '';
|
||||
|
||||
/// Whether or not this should be displayed
|
||||
/// in settings.
|
||||
bool get isDisplayable => true;
|
||||
}
|
8
lib/models/fetch_mode.dart
Normal file
@ -0,0 +1,8 @@
|
||||
enum FetchMode {
|
||||
lazy('Lazy'),
|
||||
eager('Eager');
|
||||
|
||||
const FetchMode(this.description);
|
||||
|
||||
final String description;
|
||||
}
|
10
lib/models/font.dart
Normal file
@ -0,0 +1,10 @@
|
||||
enum Font {
|
||||
roboto('Roboto'),
|
||||
robotoSlab('Roboto Slab'),
|
||||
ubuntu('Ubuntu'),
|
||||
ubuntuMono('Ubuntu Mono');
|
||||
|
||||
const Font(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
13
lib/models/font_size.dart
Normal file
@ -0,0 +1,13 @@
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
enum FontSize {
|
||||
small('Small', TextDimens.pt15),
|
||||
regular('Regular', TextDimens.pt16),
|
||||
large('Large', TextDimens.pt17),
|
||||
xlarge('XLarge', TextDimens.pt18);
|
||||
|
||||
const FontSize(this.description, this.fontSize);
|
||||
|
||||
final String description;
|
||||
final double fontSize;
|
||||
}
|
5
lib/models/item/buildable.dart
Normal file
@ -0,0 +1,5 @@
|
||||
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
|
||||
|
||||
mixin Buildable {
|
||||
List<LinkifyElement> get elements;
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/models/comment.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/models/item/buildable.dart';
|
||||
import 'package:hacki/models/item/comment.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
class BuildableComment extends Comment {
|
||||
/// [BuildableComment] is a subtype of [Comment] which stores
|
||||
/// the corresponding [LinkifyElement] for faster widget building.
|
||||
class BuildableComment extends Comment with Buildable {
|
||||
BuildableComment({
|
||||
required super.id,
|
||||
required super.time,
|
||||
@ -13,6 +15,7 @@ class BuildableComment extends Comment {
|
||||
required super.kids,
|
||||
required super.dead,
|
||||
required super.deleted,
|
||||
required super.hidden,
|
||||
required super.level,
|
||||
required this.elements,
|
||||
});
|
||||
@ -29,7 +32,9 @@ class BuildableComment extends Comment {
|
||||
dead: comment.dead,
|
||||
deleted: comment.deleted,
|
||||
level: comment.level,
|
||||
hidden: comment.hidden,
|
||||
);
|
||||
|
||||
@override
|
||||
final List<LinkifyElement> elements;
|
||||
}
|
48
lib/models/item/buildable_story.dart
Normal file
@ -0,0 +1,48 @@
|
||||
import 'package:hacki/models/item/buildable.dart';
|
||||
import 'package:hacki/models/item/story.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
/// [BuildableStory] is a subtype of [Story] which stores
|
||||
/// the corresponding [LinkifyElement] for faster widget building.
|
||||
class BuildableStory extends Story with Buildable {
|
||||
const BuildableStory({
|
||||
required super.id,
|
||||
required super.time,
|
||||
required super.score,
|
||||
required super.by,
|
||||
required super.text,
|
||||
required super.kids,
|
||||
required super.descendants,
|
||||
required super.title,
|
||||
required super.type,
|
||||
required super.url,
|
||||
required super.parts,
|
||||
required super.hidden,
|
||||
required this.elements,
|
||||
});
|
||||
|
||||
BuildableStory.fromStory(Story story, {required this.elements})
|
||||
: super(
|
||||
id: story.id,
|
||||
time: story.time,
|
||||
score: story.score,
|
||||
by: story.by,
|
||||
text: story.text,
|
||||
kids: story.kids,
|
||||
descendants: story.descendants,
|
||||
title: story.title,
|
||||
type: story.type,
|
||||
url: story.url,
|
||||
parts: story.parts,
|
||||
hidden: story.hidden,
|
||||
);
|
||||
|
||||
BuildableStory.fromTitleOnlyStory(Story story)
|
||||
: this.fromStory(
|
||||
story,
|
||||
elements: const <LinkifyElement>[],
|
||||
);
|
||||
|
||||
@override
|
||||
final List<LinkifyElement> elements;
|
||||
}
|
51
lib/models/item/comment.dart
Normal file
@ -0,0 +1,51 @@
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
|
||||
class Comment extends Item {
|
||||
Comment({
|
||||
required super.id,
|
||||
required super.time,
|
||||
required super.parent,
|
||||
required super.score,
|
||||
required super.by,
|
||||
required super.text,
|
||||
required super.kids,
|
||||
required super.dead,
|
||||
required super.deleted,
|
||||
required super.hidden,
|
||||
required this.level,
|
||||
}) : super(
|
||||
descendants: 0,
|
||||
parts: <int>[],
|
||||
title: '',
|
||||
url: '',
|
||||
type: '',
|
||||
);
|
||||
|
||||
Comment.fromJson(super.json, {this.level = 0}) : super.fromJson();
|
||||
|
||||
final int level;
|
||||
|
||||
String get metadata => '''by $by $timeAgo''';
|
||||
|
||||
Comment copyWith({
|
||||
int? level,
|
||||
bool? hidden,
|
||||
}) {
|
||||
return Comment(
|
||||
id: id,
|
||||
time: time,
|
||||
parent: parent,
|
||||
score: score,
|
||||
by: by,
|
||||
text: text,
|
||||
kids: kids,
|
||||
dead: dead,
|
||||
deleted: deleted,
|
||||
hidden: hidden ?? this.hidden,
|
||||
level: level ?? this.level,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool? get stringify => false;
|
||||
}
|
@ -1,6 +1,17 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/extensions/date_time_extension.dart';
|
||||
import 'package:hacki/models/item/comment.dart';
|
||||
import 'package:hacki/models/item/poll_option.dart';
|
||||
import 'package:hacki/models/item/story.dart';
|
||||
|
||||
export 'buildable.dart';
|
||||
export 'buildable_comment.dart';
|
||||
export 'buildable_story.dart';
|
||||
export 'comment.dart';
|
||||
export 'poll_option.dart';
|
||||
export 'story.dart';
|
||||
|
||||
/// [Item] is the base type of [Story], [Comment] and [PollOption].
|
||||
class Item extends Equatable {
|
||||
const Item({
|
||||
required this.id,
|
||||
@ -17,6 +28,7 @@ class Item extends Equatable {
|
||||
required this.type,
|
||||
required this.parts,
|
||||
required this.descendants,
|
||||
required this.hidden,
|
||||
});
|
||||
|
||||
Item.empty()
|
||||
@ -28,9 +40,10 @@ class Item extends Equatable {
|
||||
title = '',
|
||||
url = '',
|
||||
kids = <int>[],
|
||||
dead = false,
|
||||
parts = <int>[],
|
||||
dead = false,
|
||||
deleted = false,
|
||||
hidden = false,
|
||||
parent = 0,
|
||||
text = '',
|
||||
type = '';
|
||||
@ -44,12 +57,13 @@ class Item extends Equatable {
|
||||
title = json['title'] as String? ?? '',
|
||||
text = json['text'] as String? ?? '',
|
||||
url = json['url'] as String? ?? '',
|
||||
kids = <int>[],
|
||||
kids = (json['kids'] as List<dynamic>?)?.cast<int>() ?? <int>[],
|
||||
dead = json['dead'] as bool? ?? false,
|
||||
deleted = json['deleted'] as bool? ?? false,
|
||||
parent = json['parent'] as int? ?? 0,
|
||||
parts = <int>[],
|
||||
type = json['type'] as String? ?? '';
|
||||
parts = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
|
||||
type = json['type'] as String? ?? '',
|
||||
hidden = json['hidden'] as bool? ?? false;
|
||||
|
||||
final int id;
|
||||
final int time;
|
||||
@ -62,6 +76,11 @@ class Item extends Equatable {
|
||||
final bool deleted;
|
||||
final bool dead;
|
||||
|
||||
/// Whether or not the item should be hidden.
|
||||
/// true if any of filter keywords set by user presents in [text]
|
||||
/// or [title].
|
||||
final bool hidden;
|
||||
|
||||
final String by;
|
||||
final String text;
|
||||
final String url;
|
||||
@ -71,8 +90,8 @@ class Item extends Equatable {
|
||||
final List<int> kids;
|
||||
final List<int> parts;
|
||||
|
||||
String get postedDate =>
|
||||
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
|
||||
String get timeAgo =>
|
||||
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString();
|
||||
|
||||
bool get isPoll => type == 'poll';
|
||||
|
||||
@ -97,6 +116,7 @@ class Item extends Equatable {
|
||||
'deleted': deleted,
|
||||
'type': type,
|
||||
'parts': parts,
|
||||
'parent': parent,
|
||||
};
|
||||
}
|
||||
|
||||
@ -116,5 +136,6 @@ class Item extends Equatable {
|
||||
type,
|
||||
parts,
|
||||
descendants,
|
||||
hidden,
|
||||
];
|
||||
}
|
67
lib/models/item/poll_option.dart
Normal file
@ -0,0 +1,67 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
|
||||
class PollOption extends Item {
|
||||
const PollOption({
|
||||
required super.id,
|
||||
required super.score,
|
||||
required super.time,
|
||||
required super.parent,
|
||||
required super.by,
|
||||
required super.title,
|
||||
required super.text,
|
||||
required super.type,
|
||||
required super.url,
|
||||
required super.kids,
|
||||
required super.parts,
|
||||
required this.ratio,
|
||||
}) : super(
|
||||
descendants: 0,
|
||||
dead: false,
|
||||
deleted: false,
|
||||
hidden: false,
|
||||
);
|
||||
|
||||
PollOption.empty()
|
||||
: ratio = 0,
|
||||
super.empty();
|
||||
|
||||
PollOption.fromJson(super.json)
|
||||
: ratio = 0,
|
||||
super.fromJson();
|
||||
|
||||
final double ratio;
|
||||
|
||||
PollOption copyWith({double? ratio}) {
|
||||
return PollOption(
|
||||
id: id,
|
||||
score: score,
|
||||
time: time,
|
||||
parent: parent,
|
||||
by: by,
|
||||
title: title,
|
||||
text: text,
|
||||
type: type,
|
||||
url: url,
|
||||
kids: kids,
|
||||
parts: parts,
|
||||
ratio: ratio ?? this.ratio,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
...super.toJson(),
|
||||
'ratio': ratio,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final String prettyString =
|
||||
const JsonEncoder.withIndent(' ').convert(this);
|
||||
return 'PollOption $prettyString';
|
||||
}
|
||||
}
|
81
lib/models/item/story.dart
Normal file
@ -0,0 +1,81 @@
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
|
||||
class Story extends Item {
|
||||
const Story({
|
||||
required super.descendants,
|
||||
required super.id,
|
||||
required super.score,
|
||||
required super.time,
|
||||
required super.by,
|
||||
required super.title,
|
||||
required super.type,
|
||||
required super.url,
|
||||
required super.text,
|
||||
required super.kids,
|
||||
required super.parts,
|
||||
required super.hidden,
|
||||
}) : super(
|
||||
dead: false,
|
||||
deleted: false,
|
||||
parent: 0,
|
||||
);
|
||||
|
||||
Story.empty() : super.empty();
|
||||
|
||||
Story.placeholder()
|
||||
: super(
|
||||
id: 0,
|
||||
score: 0,
|
||||
descendants: 0,
|
||||
time: 1171872000,
|
||||
by: 'Y Combinator',
|
||||
title: 'Hacker News Guidelines',
|
||||
url: Constants.guidelineLink,
|
||||
kids: <int>[],
|
||||
dead: false,
|
||||
parts: <int>[],
|
||||
deleted: false,
|
||||
parent: 0,
|
||||
text: '',
|
||||
type: '',
|
||||
hidden: false,
|
||||
);
|
||||
|
||||
Story.fromJson(super.json) : super.fromJson();
|
||||
|
||||
Story copyWith({bool? hidden}) {
|
||||
return Story(
|
||||
descendants: descendants,
|
||||
id: id,
|
||||
score: score,
|
||||
time: time,
|
||||
by: by,
|
||||
title: title,
|
||||
type: type,
|
||||
url: url,
|
||||
text: text,
|
||||
kids: kids,
|
||||
parts: parts,
|
||||
hidden: hidden ?? this.hidden,
|
||||
);
|
||||
}
|
||||
|
||||
String get metadata =>
|
||||
'''$score point${score > 1 ? 's' : ''} by $by $timeAgo | $descendants comment${descendants > 1 ? 's' : ''}''';
|
||||
|
||||
String get screenReaderLabel =>
|
||||
'''$title, at $readableUrl, by $by $timeAgo. This story has $score point${score > 1 ? 's' : ''} and $descendants comment${descendants > 1 ? 's' : ''}''';
|
||||
|
||||
String get simpleMetadata =>
|
||||
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $timeAgo''';
|
||||
|
||||
String get readableUrl {
|
||||
final Uri url = Uri.parse(this.url);
|
||||
final String authority = url.authority.replaceFirst('www.', '');
|
||||
return authority;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'Story $id';
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
export 'buildable_comment.dart';
|
||||
export 'comment.dart';
|
||||
export 'item.dart';
|
||||
export 'poll_option.dart';
|
||||
export 'comments_order.dart';
|
||||
export 'fetch_mode.dart';
|
||||
export 'font.dart';
|
||||
export 'font_size.dart';
|
||||
export 'item/item.dart';
|
||||
export 'post_data.dart';
|
||||
export 'search_filters.dart';
|
||||
export 'story.dart';
|
||||
export 'preference.dart';
|
||||
export 'search_params.dart';
|
||||
export 'story_type.dart';
|
||||
export 'user.dart';
|
||||
|
@ -1,126 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hacki/models/item.dart';
|
||||
|
||||
class PollOption extends Item {
|
||||
const PollOption({
|
||||
required super.id,
|
||||
required super.score,
|
||||
required super.time,
|
||||
required super.parent,
|
||||
required super.by,
|
||||
required super.title,
|
||||
required super.text,
|
||||
required super.type,
|
||||
required super.url,
|
||||
required super.kids,
|
||||
required super.parts,
|
||||
required this.ratio,
|
||||
}) : super(
|
||||
descendants: 0,
|
||||
dead: false,
|
||||
deleted: false,
|
||||
);
|
||||
|
||||
PollOption.empty()
|
||||
: ratio = 0,
|
||||
super(
|
||||
id: 0,
|
||||
score: 0,
|
||||
descendants: 0,
|
||||
time: 0,
|
||||
by: '',
|
||||
title: '',
|
||||
url: '',
|
||||
kids: <int>[],
|
||||
dead: false,
|
||||
parts: <int>[],
|
||||
deleted: false,
|
||||
parent: 0,
|
||||
text: '',
|
||||
type: '',
|
||||
);
|
||||
|
||||
PollOption.fromJson(Map<String, dynamic> json)
|
||||
: ratio = 0,
|
||||
super(
|
||||
descendants: 0,
|
||||
id: json['id'] as int? ?? 0,
|
||||
score: json['score'] as int? ?? 0,
|
||||
time: json['time'] as int? ?? 0,
|
||||
by: json['by'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
kids: <int>[],
|
||||
text: json['text'] as String? ?? '',
|
||||
dead: json['dead'] as bool? ?? false,
|
||||
deleted: json['deleted'] as bool? ?? false,
|
||||
type: json['type'] as String? ?? '',
|
||||
parts: <int>[],
|
||||
parent: 0,
|
||||
);
|
||||
|
||||
final double ratio;
|
||||
|
||||
PollOption copyWith({double? ratio}) {
|
||||
return PollOption(
|
||||
id: id,
|
||||
score: score,
|
||||
time: time,
|
||||
parent: parent,
|
||||
by: by,
|
||||
title: title,
|
||||
text: text,
|
||||
type: type,
|
||||
url: url,
|
||||
kids: kids,
|
||||
parts: parts,
|
||||
ratio: ratio ?? this.ratio,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'descendants': descendants,
|
||||
'id': id,
|
||||
'score': score,
|
||||
'time': time,
|
||||
'by': by,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'kids': kids,
|
||||
'text': text,
|
||||
'dead': dead,
|
||||
'deleted': deleted,
|
||||
'type': type,
|
||||
'parts': parts,
|
||||
'ratio': ratio,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final String prettyString =
|
||||
const JsonEncoder.withIndent(' ').convert(this);
|
||||
return 'PollOption $prettyString';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
score,
|
||||
descendants,
|
||||
time,
|
||||
by,
|
||||
title,
|
||||
url,
|
||||
kids,
|
||||
dead,
|
||||
parts,
|
||||
deleted,
|
||||
parent,
|
||||
text,
|
||||
type,
|
||||
];
|
||||
}
|
344
lib/models/preference.dart
Normal file
@ -0,0 +1,344 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/models/displayable.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
|
||||
abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
const Preference({required this.val});
|
||||
|
||||
final T val;
|
||||
|
||||
String get key;
|
||||
|
||||
Preference<T> copyWith({required T? val});
|
||||
|
||||
static final List<Preference<dynamic>> allPreferences =
|
||||
UnmodifiableListView<Preference<dynamic>>(
|
||||
<Preference<dynamic>>[
|
||||
// Order of these preferences does not matter.
|
||||
FetchModePreference(),
|
||||
CommentsOrderPreference(),
|
||||
FontPreference(),
|
||||
FontSizePreference(),
|
||||
TabOrderPreference(),
|
||||
// Order of items below matters and
|
||||
// reflects the order on settings screen.
|
||||
const DisplayModePreference(),
|
||||
const MetadataModePreference(),
|
||||
const StoryUrlModePreference(),
|
||||
const NotificationModePreference(),
|
||||
const SwipeGesturePreference(),
|
||||
const CollapseModePreference(),
|
||||
const ReaderModePreference(),
|
||||
const MarkReadStoriesModePreference(),
|
||||
const EyeCandyModePreference(),
|
||||
const TrueDarkModePreference(),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[key];
|
||||
}
|
||||
|
||||
abstract class BooleanPreference extends Preference<bool> {
|
||||
const BooleanPreference({required super.val});
|
||||
}
|
||||
|
||||
abstract class IntPreference extends Preference<int> {
|
||||
const IntPreference({required super.val});
|
||||
}
|
||||
|
||||
const bool _notificationModeDefaultValue = true;
|
||||
const bool _swipeGestureModeDefaultValue = false;
|
||||
const bool _displayModeDefaultValue = true;
|
||||
const bool _eyeCandyModeDefaultValue = false;
|
||||
const bool _trueDarkModeDefaultValue = false;
|
||||
const bool _readerModeDefaultValue = true;
|
||||
const bool _markReadStoriesModeDefaultValue = true;
|
||||
const bool _metadataModeDefaultValue = true;
|
||||
const bool _storyUrlModeDefaultValue = true;
|
||||
const bool _collapseModeDefaultValue = true;
|
||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||
final int _fontSizeDefaultValue = FontSize.regular.index;
|
||||
final int _fontDefaultValue = Font.roboto.index;
|
||||
final int _tabOrderDefaultValue =
|
||||
StoryType.convertToSettingsValue(StoryType.values);
|
||||
|
||||
class SwipeGesturePreference extends BooleanPreference {
|
||||
const SwipeGesturePreference({bool? val})
|
||||
: super(val: val ?? _swipeGestureModeDefaultValue);
|
||||
|
||||
@override
|
||||
SwipeGesturePreference copyWith({required bool? val}) {
|
||||
return SwipeGesturePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'swipeGestureMode';
|
||||
|
||||
@override
|
||||
String get title => 'Enable Swipe Gesture';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
|
||||
}
|
||||
|
||||
class NotificationModePreference extends BooleanPreference {
|
||||
const NotificationModePreference({bool? val})
|
||||
: super(val: val ?? _notificationModeDefaultValue);
|
||||
|
||||
@override
|
||||
NotificationModePreference copyWith({required bool? val}) {
|
||||
return NotificationModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'notificationMode';
|
||||
|
||||
@override
|
||||
String get title => 'Notification on New Reply';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
'''Hacki scans for new replies to your 15 most recent comments or stories every 5 minutes while the app is running in the foreground.''';
|
||||
}
|
||||
|
||||
class CollapseModePreference extends BooleanPreference {
|
||||
const CollapseModePreference({bool? val})
|
||||
: super(val: val ?? _collapseModeDefaultValue);
|
||||
|
||||
@override
|
||||
CollapseModePreference copyWith({required bool? val}) {
|
||||
return CollapseModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'collapseMode';
|
||||
|
||||
@override
|
||||
String get title => 'Tap Anywhere to Collapse';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
'''if disabled, tap on the top of comment tile to collapse.''';
|
||||
}
|
||||
|
||||
/// The value deciding whether or not the story
|
||||
/// tile should display link preview. Defaults to true.
|
||||
class DisplayModePreference extends BooleanPreference {
|
||||
const DisplayModePreference({bool? val})
|
||||
: super(val: val ?? _displayModeDefaultValue);
|
||||
|
||||
@override
|
||||
DisplayModePreference copyWith({required bool? val}) {
|
||||
return DisplayModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'displayMode';
|
||||
|
||||
@override
|
||||
String get title => 'Complex Story Tile';
|
||||
|
||||
@override
|
||||
String get subtitle => 'show web preview in story tile.';
|
||||
}
|
||||
|
||||
class MetadataModePreference extends BooleanPreference {
|
||||
const MetadataModePreference({bool? val})
|
||||
: super(val: val ?? _metadataModeDefaultValue);
|
||||
|
||||
@override
|
||||
MetadataModePreference copyWith({required bool? val}) {
|
||||
return MetadataModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'metadataMode';
|
||||
|
||||
@override
|
||||
String get title => 'Show Metadata';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
'''show number of comments and post date in story tile.''';
|
||||
}
|
||||
|
||||
class StoryUrlModePreference extends BooleanPreference {
|
||||
const StoryUrlModePreference({bool? val})
|
||||
: super(val: val ?? _storyUrlModeDefaultValue);
|
||||
|
||||
@override
|
||||
StoryUrlModePreference copyWith({required bool? val}) {
|
||||
return StoryUrlModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'storyUrlMode';
|
||||
|
||||
@override
|
||||
String get title => 'Show Url';
|
||||
|
||||
@override
|
||||
String get subtitle => '''show url in story tile.''';
|
||||
}
|
||||
|
||||
class ReaderModePreference extends BooleanPreference {
|
||||
const ReaderModePreference({bool? val})
|
||||
: super(val: val ?? _readerModeDefaultValue);
|
||||
|
||||
@override
|
||||
ReaderModePreference copyWith({required bool? val}) {
|
||||
return ReaderModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'readerMode';
|
||||
|
||||
@override
|
||||
String get title => 'Use Reader';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
'''enter reader mode in Safari directly when it is available.''';
|
||||
|
||||
@override
|
||||
bool get isDisplayable => Platform.isIOS;
|
||||
}
|
||||
|
||||
class MarkReadStoriesModePreference extends BooleanPreference {
|
||||
const MarkReadStoriesModePreference({bool? val})
|
||||
: super(val: val ?? _markReadStoriesModeDefaultValue);
|
||||
|
||||
@override
|
||||
MarkReadStoriesModePreference copyWith({required bool? val}) {
|
||||
return MarkReadStoriesModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'markReadStoriesMode';
|
||||
|
||||
@override
|
||||
String get title => 'Mark Read Stories';
|
||||
|
||||
@override
|
||||
String get subtitle => 'grey out stories you have read.';
|
||||
}
|
||||
|
||||
class EyeCandyModePreference extends BooleanPreference {
|
||||
const EyeCandyModePreference({bool? val})
|
||||
: super(val: val ?? _eyeCandyModeDefaultValue);
|
||||
|
||||
@override
|
||||
EyeCandyModePreference copyWith({required bool? val}) {
|
||||
return EyeCandyModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'eyeCandyMode';
|
||||
|
||||
@override
|
||||
String get title => 'Eye Candy';
|
||||
|
||||
@override
|
||||
String get subtitle => 'some sort of magic.';
|
||||
}
|
||||
|
||||
class TrueDarkModePreference extends BooleanPreference {
|
||||
const TrueDarkModePreference({bool? val})
|
||||
: super(val: val ?? _trueDarkModeDefaultValue);
|
||||
|
||||
@override
|
||||
TrueDarkModePreference copyWith({required bool? val}) {
|
||||
return TrueDarkModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'trueDarkMode';
|
||||
|
||||
@override
|
||||
String get title => 'True Dark Mode';
|
||||
|
||||
@override
|
||||
String get subtitle => 'you might need to restart the app.';
|
||||
}
|
||||
|
||||
class FetchModePreference extends IntPreference {
|
||||
FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
|
||||
|
||||
@override
|
||||
FetchModePreference copyWith({required int? val}) {
|
||||
return FetchModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'fetchMode';
|
||||
|
||||
@override
|
||||
String get title => 'Default fetch mode';
|
||||
}
|
||||
|
||||
class CommentsOrderPreference extends IntPreference {
|
||||
CommentsOrderPreference({int? val})
|
||||
: super(val: val ?? _commentsOrderDefaultValue);
|
||||
|
||||
@override
|
||||
CommentsOrderPreference copyWith({required int? val}) {
|
||||
return CommentsOrderPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'commentsOrder';
|
||||
|
||||
@override
|
||||
String get title => 'Default comments order';
|
||||
}
|
||||
|
||||
class FontPreference extends IntPreference {
|
||||
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
|
||||
|
||||
@override
|
||||
FontPreference copyWith({required int? val}) {
|
||||
return FontPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'font';
|
||||
|
||||
@override
|
||||
String get title => 'Default font';
|
||||
}
|
||||
|
||||
class FontSizePreference extends IntPreference {
|
||||
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);
|
||||
|
||||
@override
|
||||
FontSizePreference copyWith({required int? val}) {
|
||||
return FontSizePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'fontSize';
|
||||
|
||||
@override
|
||||
String get title => 'Default font size';
|
||||
}
|
||||
|
||||
class TabOrderPreference extends IntPreference {
|
||||
TabOrderPreference({int? val}) : super(val: val ?? _tabOrderDefaultValue);
|
||||
|
||||
@override
|
||||
TabOrderPreference copyWith({required int? val}) {
|
||||
return TabOrderPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'tabOrder';
|
||||
|
||||
@override
|
||||
String get title => 'Tab order';
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
part of 'search_filters.dart';
|
||||
part of 'search_params.dart';
|
||||
|
||||
abstract class SearchFilter {
|
||||
String get query;
|
||||
@ -8,8 +8,19 @@ abstract class NumericFilter extends SearchFilter {}
|
||||
|
||||
abstract class TagFilter extends SearchFilter {}
|
||||
|
||||
abstract class TypeTagFilter extends TagFilter {
|
||||
static List<TypeTagFilter> all = <TypeTagFilter>[
|
||||
const StoryFilter(),
|
||||
const PollFilter(),
|
||||
const CommentFilter(),
|
||||
const FrontPageFilter(),
|
||||
const AskHnFilter(),
|
||||
const ShowHnFilter(),
|
||||
];
|
||||
}
|
||||
|
||||
class DateTimeRangeFilter implements NumericFilter {
|
||||
DateTimeRangeFilter({
|
||||
const DateTimeRangeFilter({
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
});
|
||||
@ -37,7 +48,7 @@ class DateTimeRangeFilter implements NumericFilter {
|
||||
}
|
||||
|
||||
class PostedByFilter implements TagFilter {
|
||||
PostedByFilter({required this.author});
|
||||
const PostedByFilter({required this.author});
|
||||
|
||||
final String author;
|
||||
|
||||
@ -47,8 +58,8 @@ class PostedByFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class FrontPageFilter implements TagFilter {
|
||||
FrontPageFilter();
|
||||
class FrontPageFilter implements TypeTagFilter {
|
||||
const FrontPageFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -56,8 +67,8 @@ class FrontPageFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class ShowHnFilter implements TagFilter {
|
||||
ShowHnFilter();
|
||||
class ShowHnFilter implements TypeTagFilter {
|
||||
const ShowHnFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -65,8 +76,8 @@ class ShowHnFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class AskHnFilter implements TagFilter {
|
||||
AskHnFilter();
|
||||
class AskHnFilter implements TypeTagFilter {
|
||||
const AskHnFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -74,8 +85,8 @@ class AskHnFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class PollFilter implements TagFilter {
|
||||
PollFilter();
|
||||
class PollFilter implements TypeTagFilter {
|
||||
const PollFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -83,8 +94,8 @@ class PollFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class StoryFilter implements TagFilter {
|
||||
StoryFilter();
|
||||
class StoryFilter implements TypeTagFilter {
|
||||
const StoryFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -92,8 +103,17 @@ class StoryFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class CommentFilter implements TypeTagFilter {
|
||||
const CommentFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
return 'comment';
|
||||
}
|
||||
}
|
||||
|
||||
class CombinedFilter implements TagFilter {
|
||||
CombinedFilter({required this.filters});
|
||||
const CombinedFilter({required this.filters});
|
||||
|
||||
final List<TagFilter> filters;
|
||||
|
||||
|
@ -3,15 +3,15 @@ import 'package:equatable/equatable.dart';
|
||||
|
||||
part 'search_filter.dart';
|
||||
|
||||
class SearchFilters extends Equatable {
|
||||
const SearchFilters({
|
||||
class SearchParams extends Equatable {
|
||||
const SearchParams({
|
||||
required this.filters,
|
||||
required this.query,
|
||||
required this.page,
|
||||
this.sorted = false,
|
||||
});
|
||||
|
||||
SearchFilters.init()
|
||||
SearchParams.init()
|
||||
: filters = <SearchFilter>{},
|
||||
query = '',
|
||||
page = 0,
|
||||
@ -22,13 +22,13 @@ class SearchFilters extends Equatable {
|
||||
final int page;
|
||||
final bool sorted;
|
||||
|
||||
SearchFilters copyWith({
|
||||
SearchParams copyWith({
|
||||
Set<SearchFilter>? filters,
|
||||
String? query,
|
||||
int? page,
|
||||
bool? sorted,
|
||||
}) {
|
||||
return SearchFilters(
|
||||
return SearchParams(
|
||||
filters: filters ?? this.filters,
|
||||
query: query ?? this.query,
|
||||
page: page ?? this.page,
|
||||
@ -36,8 +36,8 @@ class SearchFilters extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
SearchFilters copyWithFilterRemoved<T extends SearchFilter>() {
|
||||
return SearchFilters(
|
||||
SearchParams copyWithFilterRemoved<T extends SearchFilter>() {
|
||||
return SearchParams(
|
||||
filters: <SearchFilter>{...filters}
|
||||
..removeWhere((SearchFilter e) => e is T),
|
||||
query: query,
|
||||
@ -46,10 +46,10 @@ class SearchFilters extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
SearchFilters copyWithFilterAdded(
|
||||
SearchParams copyWithFilterAdded(
|
||||
SearchFilter filter,
|
||||
) {
|
||||
return SearchFilters(
|
||||
return SearchParams(
|
||||
filters: <SearchFilter>{...filters, filter},
|
||||
query: query,
|
||||
page: page,
|
||||
@ -70,7 +70,6 @@ class SearchFilters extends Equatable {
|
||||
filters.whereType<NumericFilter>();
|
||||
final List<TagFilter> tagFilters = <TagFilter>[
|
||||
...filters.whereType<TagFilter>(),
|
||||
CombinedFilter(filters: <TagFilter>[StoryFilter(), PollFilter()]),
|
||||
];
|
||||
|
||||
if (numericFilters.isNotEmpty) {
|
@ -1,139 +0,0 @@
|
||||
import 'package:hacki/models/item.dart';
|
||||
|
||||
enum StoryType {
|
||||
top('topstories'),
|
||||
best('beststories'),
|
||||
latest('newstories'),
|
||||
ask('askstories'),
|
||||
show('showstories'),
|
||||
jobs('jobstories');
|
||||
|
||||
const StoryType(this.path);
|
||||
|
||||
final String path;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case StoryType.top:
|
||||
return 'TOP';
|
||||
case StoryType.best:
|
||||
return 'BEST';
|
||||
case StoryType.latest:
|
||||
return 'NEW';
|
||||
case StoryType.ask:
|
||||
return 'ASK';
|
||||
case StoryType.show:
|
||||
return 'SHOW';
|
||||
case StoryType.jobs:
|
||||
return 'JOBS';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Story extends Item {
|
||||
const Story({
|
||||
required super.descendants,
|
||||
required super.id,
|
||||
required super.score,
|
||||
required super.time,
|
||||
required super.by,
|
||||
required super.title,
|
||||
required super.type,
|
||||
required super.url,
|
||||
required super.text,
|
||||
required super.kids,
|
||||
required super.parts,
|
||||
}) : super(
|
||||
dead: false,
|
||||
deleted: false,
|
||||
parent: 0,
|
||||
);
|
||||
|
||||
Story.empty()
|
||||
: super(
|
||||
id: 0,
|
||||
score: 0,
|
||||
descendants: 0,
|
||||
time: 0,
|
||||
by: '',
|
||||
title: '',
|
||||
url: '',
|
||||
kids: <int>[],
|
||||
dead: false,
|
||||
parts: <int>[],
|
||||
deleted: false,
|
||||
parent: 0,
|
||||
text: '',
|
||||
type: '',
|
||||
);
|
||||
|
||||
Story.fromJson(Map<String, dynamic> json)
|
||||
: super(
|
||||
descendants: json['descendants'] as int? ?? 0,
|
||||
id: json['id'] as int? ?? 0,
|
||||
score: json['score'] as int? ?? 0,
|
||||
time: json['time'] as int? ?? 0,
|
||||
by: json['by'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
kids: (json['kids'] as List<dynamic>?)?.cast<int>() ?? <int>[],
|
||||
text: json['text'] as String? ?? '',
|
||||
dead: json['dead'] as bool? ?? false,
|
||||
deleted: json['deleted'] as bool? ?? false,
|
||||
type: json['type'] as String? ?? '',
|
||||
parts: (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
|
||||
parent: 0,
|
||||
);
|
||||
|
||||
String get metadata =>
|
||||
'''$score point${score > 1 ? 's' : ''} by $by $postedDate | $descendants comment${descendants > 1 ? 's' : ''}''';
|
||||
|
||||
String get simpleMetadata =>
|
||||
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate''';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'descendants': descendants,
|
||||
'id': id,
|
||||
'score': score,
|
||||
'time': time,
|
||||
'by': by,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'kids': kids,
|
||||
'text': text,
|
||||
'dead': dead,
|
||||
'deleted': deleted,
|
||||
'type': type,
|
||||
'parts': parts,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
// final String prettyString =
|
||||
// const JsonEncoder.withIndent(' ').convert(this);
|
||||
// return 'Story $prettyString';
|
||||
return 'Story $id';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
score,
|
||||
descendants,
|
||||
time,
|
||||
by,
|
||||
title,
|
||||
text,
|
||||
url,
|
||||
kids,
|
||||
dead,
|
||||
parts,
|
||||
deleted,
|
||||
parent,
|
||||
text,
|
||||
type,
|
||||
];
|
||||
}
|
34
lib/models/story_type.dart
Normal file
@ -0,0 +1,34 @@
|
||||
enum StoryType {
|
||||
top('topstories'),
|
||||
best('beststories'),
|
||||
latest('newstories'),
|
||||
ask('askstories'),
|
||||
show('showstories');
|
||||
|
||||
const StoryType(this.path);
|
||||
|
||||
final String path;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case StoryType.top:
|
||||
return 'TOP';
|
||||
case StoryType.best:
|
||||
return 'BEST';
|
||||
case StoryType.latest:
|
||||
return 'NEW';
|
||||
case StoryType.ask:
|
||||
return 'ASK';
|
||||
case StoryType.show:
|
||||
return 'SHOW';
|
||||
}
|
||||
}
|
||||
|
||||
static int convertToSettingsValue(List<StoryType> tabs) {
|
||||
return int.parse(
|
||||
tabs
|
||||
.map((StoryType e) => e.index.toString())
|
||||
.reduce((String value, String element) => '$value$element'),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class User {
|
||||
User({
|
||||
class User extends Equatable {
|
||||
const User({
|
||||
required this.about,
|
||||
required this.created,
|
||||
required this.delay,
|
||||
@ -9,13 +10,19 @@ class User {
|
||||
required this.karma,
|
||||
});
|
||||
|
||||
User.empty()
|
||||
const User.empty()
|
||||
: about = '',
|
||||
created = 0,
|
||||
delay = 0,
|
||||
id = '',
|
||||
karma = 0;
|
||||
|
||||
const User.emptyWithId(this.id)
|
||||
: about = '',
|
||||
created = 0,
|
||||
delay = 0,
|
||||
karma = 0;
|
||||
|
||||
User.fromJson(Map<String, dynamic> json)
|
||||
: about = json['about'] as String? ?? '',
|
||||
created = json['created'] as int? ?? 0,
|
||||
@ -39,4 +46,13 @@ class User {
|
||||
String toString() {
|
||||
return 'User $about, $created, $delay, $id, $karma';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
about,
|
||||
created,
|
||||
delay,
|
||||
id,
|
||||
karma,
|
||||
];
|
||||
}
|
||||
|
@ -1,27 +1,29 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/post_repository.dart';
|
||||
import 'package:hacki/repositories/postable_repository.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/repositories/preference_repository.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// [AuthRepository] if for logging user in/out and performing actions
|
||||
/// that require a logged in user such as [flag], [favorite], [upvote],
|
||||
/// and [downvote].
|
||||
///
|
||||
/// For posting actions such as posting a comment, see [PostRepository].
|
||||
class AuthRepository extends PostableRepository {
|
||||
AuthRepository({
|
||||
Dio? dio,
|
||||
super.dio,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
Logger? logger,
|
||||
}) : _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(dio: dio);
|
||||
_logger = logger ?? locator.get<Logger>();
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final Logger _logger;
|
||||
|
||||
static const String _authority = 'news.ycombinator.com';
|
||||
|
||||
Future<bool> get loggedIn async => _preferenceRepository.loggedIn;
|
||||
|
||||
Future<String?> get username async => _preferenceRepository.username;
|
||||
@ -32,7 +34,7 @@ class AuthRepository extends PostableRepository {
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final Uri uri = Uri.https(_authority, 'login');
|
||||
final Uri uri = Uri.https(authority, 'login');
|
||||
final PostDataMixin data = LoginPostData(
|
||||
acct: username,
|
||||
pw: password,
|
||||
@ -66,7 +68,7 @@ class AuthRepository extends PostableRepository {
|
||||
required int id,
|
||||
required bool flag,
|
||||
}) async {
|
||||
final Uri uri = Uri.https(_authority, 'flag');
|
||||
final Uri uri = Uri.https(authority, 'flag');
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
final PostDataMixin data = FlagPostData(
|
||||
@ -83,7 +85,7 @@ class AuthRepository extends PostableRepository {
|
||||
required int id,
|
||||
required bool favorite,
|
||||
}) async {
|
||||
final Uri uri = Uri.https(_authority, 'fave');
|
||||
final Uri uri = Uri.https(authority, 'fave');
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
final PostDataMixin data = FavoritePostData(
|
||||
@ -100,7 +102,7 @@ class AuthRepository extends PostableRepository {
|
||||
required int id,
|
||||
required bool upvote,
|
||||
}) async {
|
||||
final Uri uri = Uri.https(_authority, 'vote');
|
||||
final Uri uri = Uri.https(authority, 'vote');
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
final PostDataMixin data = VotePostData(
|
||||
@ -117,7 +119,7 @@ class AuthRepository extends PostableRepository {
|
||||
required int id,
|
||||
required bool downvote,
|
||||
}) async {
|
||||
final Uri uri = Uri.https(_authority, 'vote');
|
||||
final Uri uri = Uri.https(authority, 'vote');
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
final PostDataMixin data = VotePostData(
|
||||
|