Compare commits

...

70 Commits

Author SHA1 Message Date
fe162208ca fix expand animation. (#142) 2023-02-10 14:08:31 -08:00
58139ba7a3 update commit_check.yml (#141) 2023-02-09 15:42:08 -08:00
33a31acbe2 update Fastfile. 2023-02-09 15:20:19 -08:00
0fcfcbb7e3 update Fastfile. (#140) 2023-02-09 15:12:00 -08:00
a98f52c90b update publish_ios.yml 2023-02-09 14:37:11 -08:00
8e8e48c44a update GitHub action. (#139) 2023-02-09 14:28:46 -08:00
603b7cc939 bump flutter to 3.7.3 (#138) 2023-02-09 11:27:03 -08:00
649fa33df3 fix err msg. (#137) 2023-02-09 00:19:34 -08:00
81d4a0f2df banner cleanup. (#136) 2023-02-08 23:44:15 -08:00
24112a471e add collapse/expand animation to comment tile. (#135) 2023-02-08 23:08:09 -08:00
c7824eaef3 bump flutter to 3.7.2 (#134) 2023-02-08 17:43:23 -08:00
c2b66d29c3 add sharing option. (#131) 2023-02-04 18:46:04 -08:00
e0a53e44b2 bump flutter to 3.7.1 (#129) 2023-02-01 15:19:06 -08:00
4cf8379db0 fix Story model. (#128) 2023-01-31 22:02:17 -08:00
c1c26bf0e0 fix preference model. (#127) 2023-01-31 18:19:34 -08:00
29e2f4163d fix offline mode. (#126) 2023-01-31 16:54:28 -08:00
c3de80015d fix PinnedStories (#125) 2023-01-31 16:36:58 -08:00
436cd9ce8b fix Item model. (#123) 2023-01-31 15:56:29 -08:00
efb326be68 refactor models. (#122) 2023-01-30 23:43:12 -08:00
047903fe24 refactor. (#121) 2023-01-30 22:46:29 -08:00
41068ddf89 cleanup. (#120) 2023-01-29 21:34:54 -08:00
196516ce85 fix logger. (#119) 2023-01-29 20:55:46 -08:00
7f647b127d enable swipe gesture. (#118) 2023-01-29 20:03:11 -08:00
a50a0874e7 fix logger. (#117) 2023-01-29 18:46:55 -08:00
b176be96fb Allow customizing tab bar. (#112) 2023-01-29 16:48:08 -08:00
1e5af07691 improve UX. (#110) 2023-01-25 21:47:05 -08:00
ecf8c902dc bump flutter and linter version. (#108) 2023-01-25 12:33:06 -08:00
d3ede8546b update link_view.dart (#107) 2023-01-19 01:15:45 -08:00
53562ad260 fix loading indicator. (#106) 2023-01-19 00:23:28 -08:00
6c8e7a7cb9 update loading indicator. (#105) 2023-01-18 16:10:25 -08:00
56c0245335 fix StoryTile. (#104) 2023-01-18 14:31:49 -08:00
0cbd38a530 fix StoryTile. (#103) 2023-01-17 23:47:04 -08:00
7c6da2c36a fix story tile overflow. (#102) 2023-01-17 23:02:57 -08:00
185140feb4 show url in story tile. (#101) 2023-01-17 21:49:10 -08:00
03c01a0b78 bump versions. (#97) 2022-12-27 13:56:21 -08:00
f823fdf241 use version from pubspec.yaml (#95) (#96) 2022-12-27 13:40:41 -08:00
fe87ddd8ff add (#95) 2022-12-27 13:36:14 -08:00
613ba12b05 fix time machine. (#94)
* bumped version.

* fix time machine.
2022-12-27 12:54:18 -08:00
8d7f66ecbc add timeout. (#93) 2022-12-27 01:17:16 -08:00
461aae253b update publish_ios.yml (#92) 2022-12-27 00:22:04 -08:00
a1b491cf0d fix regex for getting item id. (#91) 2022-12-27 00:00:10 -08:00
edf0c82040 Improve loading mechanism. (#90)
* load more comments when user folds the last comment.

* improvements.

* improve loading experience.
2022-12-26 22:55:50 -08:00
946a3c5a9a Improve loading mechanism. (#89)
* load more comments when user folds the last comment.

* improvements.
2022-12-26 22:09:31 -08:00
d8bc60c071 Add tests. (#88)
* update fontsize.

* fix title.

* fix info list.

* add small.

* nit.

* nit.

* test.

* add tests.

* update github action.
2022-12-25 20:12:11 -08:00
48477cd5c8 Fix comment tile. (#87)
* fix comment not correctly collapsing.

* fix comment tile overflow.

* bumped version to 1.0.0
2022-12-25 00:58:14 -08:00
38df6293fe update comment tile. (#86) 2022-12-22 19:32:57 -08:00
a5fe9e45fc fix NavigationModePreference 2022-12-21 11:09:59 -08:00
9de5baa77a bumped version. (#85) 2022-12-20 23:34:45 -08:00
2daccd64e8 update fontsize. (#84)
* update fontsize.

* fix title.

* fix info list.

* add small.

* nit.

* nit.
2022-12-20 22:49:33 -08:00
d0c68f9419 update Fastfile. 2022-12-20 22:10:49 -08:00
5f1dbfc510 update Fastfile 2022-12-20 21:58:45 -08:00
90eee37c17 update Fastfile 2022-12-20 21:25:21 -08:00
5630e61a74 update Fastfile 2022-12-20 21:02:34 -08:00
eaad4b01dd fix ci. (#83)
* fix ci.

* update project.

* update github checks.

* update github checks.

* nit.

* nit.

* update fastfile.

* fix info.plist

* nit.

* nit.

* nit.

* nit.

* nit.

* nit.

* nit.

* update publish_ios.yml
2022-12-20 20:37:49 -08:00
3ab172f3d3 update publish_ios.yml 2022-12-19 13:51:57 -08:00
5450eba64b fix RegExp. (#82)
* fix regexp.

* bump version.
2022-12-19 13:51:10 -08:00
e2d6bb44d0 update publish_ios.yml 2022-12-19 13:46:00 -08:00
ffbd3a2449 add flutter as submodule (#80)
* add flutter as submodule

* move flutter to submodules.

* removed unused file.

* nit.
2022-12-18 18:33:46 -08:00
2405a6d30c update publish_ios.yml 2022-12-17 18:48:16 -08:00
01085e5fd3 rename search filters. (#79) 2022-12-17 18:14:43 -08:00
b5e11a72bf add split abi. (#78) 2022-12-17 13:10:37 -08:00
f55bbb6f84 v0.2.33 (#76)
* bump version.

* tap anywhere to collapse.

* bump version.

* add feedback.

* refactor preference.

* renaming.

* bump version.

* nit.

* cleanup.

* bump version.

* add feedback.

* nit.

* nit.

* fix android icon.

* update description.
2022-12-16 22:07:55 -08:00
b3e994269c v0.2.32 (#74)
* fixed background color of nav bar on Android.

* fixed comment tile overflow.

* added github action. (#73)

* updated provisioning profile.

* updated profile.

* updated profile.

* updated profile

* updated profile.

* updated profile.

* updated fastfile.

* updated actions

* updated action.

* updated fastlane.

* revert actions.

* fixed action.

* avoid running on tag creation.

* updated action.

* bump version of Flutter.

* bump version of Flutter.
2022-10-14 19:39:26 -07:00
a2c66a0075 fixed background color of nav bar on Android. (#72) 2022-09-17 00:44:27 -07:00
5f43fd6968 v0.2.31 (#71)
* bumped version.

* bumped version.

* bumped version

* bumped version.

* downgrade flutter version.

* Use transparent color for navigation bar (#70)

* Use transparent color for navigation bar

* Fixed whitespace

Co-authored-by: Niklas Weinhart <git@weinhart.net>

* bumped ios version.

* bumped versions.

* updated min screen width.

* removed print.

* fixed stack overflow in EditCubit.

* added integration test.

* bumped version.

Co-authored-by: wnhrt <github@weinhart.net>
Co-authored-by: Niklas Weinhart <git@weinhart.net>
2022-09-16 22:37:16 -07:00
d83381a7fd v0.2.30 (#68)
* bumped version.

* cache  in case app gets killed in the background.

* bumped flutter version.

* updated

* improved 'ReplyBox'.

* fixed lint.

* fixed EditCubit.

* updated EditCubit.

* fixed navigation in tablet mode.

* clear cache after submission.
2022-08-05 22:13:30 -07:00
764ff09345 v0.2.29-hotfix (#67)
* fixed datetime extension.

* updated README.md
2022-07-06 20:37:18 -07:00
ab449adce2 v0.2.29 (#66)
* fixed stuff.

* fixed UI.

* bumped version.
2022-07-06 17:04:14 -07:00
2ec41b26f2 updated README.md 2022-07-06 16:23:05 -07:00
19f2107d95 v0.2.28 (#65)
* bumped version.

* fixed comments cubit and story tile.

* cancel subscription on error.
2022-07-02 01:14:40 -07:00
159 changed files with 6885 additions and 4533 deletions

23
.github/workflows/commit_check.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: Commit Guard
on:
push:
branches:
- "**"
- '!master'
jobs:
releases:
name: Check commit
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: checkout all the submodules
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

View File

@ -1,32 +0,0 @@
name: Releases
on:
push:
# tags:
# - '*'
jobs:
releases:
name: release apk
runs-on: ubuntu-latest
env:
JAVA_VERSION: "11.0"
FLUTTER_VERSION: "3.0.3"
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v2
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.0.3'
channel: 'stable'
- run: flutter pub get
- run: flutter analyze
# - run: flutter test
# - run: flutter build apk --release
# - uses: ncipollo/release-action@v1
# with:
# artifacts: "build/app/outputs/flutter-apk/*.apk"
# token: ${{ secrets.GITHUB_TOKEN }}

52
.github/workflows/publish_ios.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Publish (iOS)
on:
# Allow manual builds of this workflow
workflow_dispatch: {}
# Run the workflow whenever a new tag named 'v*' is pushed
push:
branches:
- master
jobs:
build_and_publish:
runs-on: macos-latest
timeout-minutes: 30
env:
# Point the `ruby/setup-ruby` action at this Gemfile, so it
# caches dependencies for us.
BUNDLE_GEMFILE: ${{ github.workspace }}/ios/Gemfile
steps:
- name: Check out from git
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
# Start an ssh-agent that will provide the SSH key from the
# SSH_PRIVATE_KEY secret to `fastlane match`
- name: Setup SSH key
env:
SSH_AUTH_SOCK: /tmp/ssh_agent.sock
run: |
ssh-agent -a $SSH_AUTH_SOCK > /dev/null
ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}"
- 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

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "flutter"]
path = submodules/flutter
url = https://github.com/flutter/flutter

1
.ruby-version Normal file
View File

@ -0,0 +1 @@
2.7.5

View File

@ -1,14 +1,13 @@
# <img width="64" src="https://user-images.githubusercontent.com/7277662/167775086-0b234f28-dee4-44f6-aae4-14a28ed4bbb6.png"> Hacki for Hacker News
A simple noiseless [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
A [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
[![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
[![Fdroid version](https://img.shields.io/f-droid/v/com.jiaqifeng.hacki)](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
[![GH version](https://img.shields.io/github/release/livinglist/hacki.svg?logo=github)](https://github.com/Livinglist/Hacki/releases/latest)
[![Visits Badge](https://badges.pufler.dev/visits/livinglist/Hacki)](https://badges.pufler.dev)
[![GitHub](https://img.shields.io/github/stars/livinglist/Hacki?style=social)](https://img.shields.io/github/stars/livinglist/Hacki?style=social)
[![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://pub.dev/packages/effective_dart)
[![GitHub](https://img.shields.io/github/stars/livinglist/Hacki?style=social)](https://img.shields.io/github/stars/livinglist/Hacki?style=social)
[<img src="assets/images/app_store_badge.png" height="50">](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone) [<img src="assets/images/google_play_badge.png" height="50">](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US) [<img src="assets/images/f_droid_badge.png" height="50">](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)

View File

@ -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/**"

View File

@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
android {
compileSdkVersion 32
compileSdkVersion 33
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -51,7 +51,7 @@ android {
defaultConfig {
applicationId "com.jiaqifeng.hacki"
minSdkVersion 26
targetSdkVersion 32
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
@ -79,3 +79,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
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
- Lazy loading.
- Offline mode now includes web pages.
- You can now sort comments in story screen.

View File

@ -0,0 +1,3 @@
- Lazy loading.
- Offline mode now includes web pages.
- You can now sort comments in story screen.

View File

@ -0,0 +1,3 @@
- Lazy loading.
- Offline mode now includes web pages.
- You can now sort comments in story screen.

View File

@ -0,0 +1,3 @@
- Lazy loading.
- Offline mode now includes web pages.
- You can now sort comments in story screen.

View File

@ -0,0 +1,2 @@
- Bumped Flutter version.
- Updated navigation bar background color.

View File

@ -0,0 +1,2 @@
- Bumped Flutter version.
- Updated navigation bar background color.

View File

@ -0,0 +1,2 @@
- Fixed app icon.
- Added font size setting to comments screen.

View File

@ -0,0 +1,2 @@
- Fixed app icon.
- Added font size setting to comments screen.

View File

@ -0,0 +1 @@
- Fixed time machine.

View File

@ -0,0 +1 @@
- Fixed time machine.

View 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.

View File

@ -1,46 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hacki/main.dart' as app;
import 'package:hacki/screens/widgets/story_tile.dart';
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('performance test', () {
testWidgets('scrolling performance on ItemScreen',
(WidgetTester tester) async {
await app.main(testing: true);
await tester.pump();
final Finder bestStoryTabFinder = find.text('BEST');
await tester.tap(bestStoryTabFinder);
await tester.pumpAndSettle(const Duration(seconds: 3));
final Finder storyTileFinder = find.byType(StoryTile);
await tester.tap(storyTileFinder.first);
await tester.pumpAndSettle(const Duration(seconds: 3));
TestGesture gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, -300));
await tester.pump();
gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, -300));
await tester.pump();
gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, -300));
await tester.pump();
gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, 900));
await tester.pump();
gesture = await tester.startGesture(const Offset(0, 300));
await gesture.moveBy(const Offset(0, -900));
await tester.pump();
});
});
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hacki/main.dart' as app;
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:integration_test/integration_test.dart';
void main() {
final IntegrationTestWidgetsFlutterBinding binding =
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Scrolling test', (WidgetTester tester) async {
await app.main(testing: true);
await tester.pumpAndSettle();
final Finder bestTabFinder = find.widgetWithText(Tab, 'BEST');
expect(bestTabFinder, findsOneWidget);
Future<void> scrollDown(WidgetTester tester) async {
await tester.timedDragFrom(
const Offset(100, 200),
const Offset(100, -700),
const Duration(seconds: 2),
);
await tester.pump();
}
Future<void> scrollUp(WidgetTester tester) async {
await tester.timedDragFrom(
const Offset(100, 200),
const Offset(100, 700),
const Duration(seconds: 1),
);
await tester.pump();
}
await binding.traceAction(
() async {
await tester.tap(bestTabFinder);
await tester.pump();
const int count = 10;
for (int i = 0; i < count; i++) {
await scrollDown(tester);
}
for (int i = 0; i < count - 3; i++) {
await scrollUp(tester);
}
await tester.pumpAndSettle(const Duration(seconds: 2));
final Finder storyFinder = find.byType(StoryTile);
expect(storyFinder, findsWidgets);
final Finder firstStoryFinder = storyFinder.first;
expect(firstStoryFinder, findsOneWidget);
await tester.tap(firstStoryFinder);
await tester.pump(const Duration(seconds: 4));
},
reportKey: 'scrolling_timeline',
);
});
}

View File

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>9.0</string>
<string>11.0</string>
</dict>
</plist>

4
ios/Gemfile Normal file
View File

@ -0,0 +1,4 @@
source "https://rubygems.org"
gem "fastlane"
gem "cocoapods"

285
ios/Gemfile.lock Normal file
View File

@ -0,0 +1,285 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
rexml
activesupport (6.1.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.680.0)
aws-sdk-core (3.168.4)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.61.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
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.2)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
cocoapods (1.11.3)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.11.3)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 1.0, < 3.0)
xcodeproj (>= 1.21.0, < 2.0)
cocoapods-core (1.11.3)
activesupport (>= 5.0, < 7)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix (~> 4.0)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.1.10)
declarative (0.0.20)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
escape (0.0.4)
ethon (0.15.0)
ffi (>= 1.15.0)
excon (0.95.0)
faraday (1.10.2)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.211.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
ffi (1.15.5)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
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)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
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.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.3.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.6.3)
jwt (2.5.0)
memoist (0.16.2)
mini_magick (4.12.0)
mini_mime (1.1.2)
minitest (5.16.3)
molinillo (0.8.0)
multi_json (1.15.0)
multipart-post (2.0.0)
nanaimo (0.3.0)
nap (1.1.0)
naturally (2.2.1)
netrc (0.11.0)
optparse (0.1.1)
os (1.1.4)
plist (3.6.0)
public_suffix (4.0.7)
rake (13.0.6)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.5)
rouge (2.0.7)
ruby-macho (2.5.1)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.8)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.7.0)
word_wrap (1.0.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
zeitwerk (2.6.0)
PLATFORMS
universal-darwin-21
x86_64-darwin-19
DEPENDENCIES
cocoapods
fastlane
BUNDLED WITH
2.3.22

View File

@ -1,6 +1,3 @@
# Uncomment this line to define a global platform for your project
platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@ -37,8 +34,5 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
end
end
end

View File

@ -3,6 +3,8 @@ PODS:
- Flutter
- ReachabilitySwift
- 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 +14,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 +24,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)
@ -48,15 +54,17 @@ PODS:
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_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`)
@ -75,6 +83,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity_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 +95,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 +120,28 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
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: ae1517e5e344f5544fb090b079e11f399dfbe4d2
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
PODFILE CHECKSUM: e4c97c7a9aacaeda4b952f7ef9ea29e47660f622
PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937
COCOAPODS: 1.11.2
COCOAPODS: 1.11.3

View File

@ -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;
};
@ -549,7 +547,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -567,20 +565,25 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = QMWX3X2NF7;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
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.27;
MARKETING_VERSION = 0.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.jiaqi.hacki";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -635,7 +638,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@ -684,7 +687,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -704,20 +707,25 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = QMWX3X2NF7;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
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.27;
MARKETING_VERSION = 0.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.jiaqi.hacki";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -735,20 +743,25 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = QMWX3X2NF7;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
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.27;
MARKETING_VERSION = 0.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.jiaqi.hacki";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -767,9 +780,11 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist";
@ -786,6 +801,8 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki share extension profile";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
@ -806,9 +823,11 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist";
@ -824,6 +843,8 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.jiaqi.hacki.Share-Extension";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -842,9 +863,11 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist";
@ -860,6 +883,8 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki share extension profile";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -880,9 +905,11 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist";
@ -899,6 +926,8 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki action extension profile";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
@ -921,9 +950,11 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist";
@ -939,6 +970,8 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.jiaqi.hacki.Action-Extension";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -959,9 +992,11 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist";
@ -977,6 +1012,8 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki action extension profile";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;

View File

@ -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,8 +16,6 @@ import flutter_local_notifications
let center = UNUserNotificationCenter.current()
center.delegate = self
HackiCore.start()
WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!)
@ -28,8 +26,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")!)
}

View File

@ -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)
}
}

View File

@ -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>

View File

@ -7,10 +7,10 @@
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
<dict>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>

8
ios/fastlane/Appfile Normal file
View File

@ -0,0 +1,8 @@
app_identifier("com.jiaqi.hacki") # The bundle identifier of your app
apple_id("georgefung78@Live.com") # Your Apple Developer Portal username
itc_team_id("120097292") # App Store Connect Team ID
team_id("QMWX3X2NF7") # Developer Portal Team ID
# For more information about the Appfile, see:
# https://docs.fastlane.tools/advanced/#appfile

80
ios/fastlane/Fastfile Normal file
View File

@ -0,0 +1,80 @@
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
## Update these lines to match your project!
# Bundle Identifier used for the iOS App on the App Store Connect portal
APP_IDENTIFIER = 'com.jiaqi.hacki'
# Issuer ID from the Keys section of https://appstoreconnect.apple.com/access/users
APPSTORECONNECT_ISSUER_ID = '0b588ac9-5b3e-4420-867a-a33decce7b91'
# Key ID from the key matching the `APP_STORE_CONNECT_API_KEY_KEY` secret, found under the Keys section of https://appstoreconnect.apple.com/access/users
APPSTORECONNECT_KEY_ID = 'DPNP8R66QS'
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:ios)
platform :ios do
desc "Push a new beta build to TestFlight"
lane :beta do |options|
setup_ci if ENV['CI']
is_example_repo = ENV['CI'] && ENV['GITHUB_REPOSITORY'] == 'jorgenpt/flutter_github_example'
# Download code signing certificates using `match` (and the `MATCH_PASSWORD` secret)
sync_code_signing(
type: "appstore",
app_identifier: [APP_IDENTIFIER, "#{APP_IDENTIFIER}.Share-Extension", "#{APP_IDENTIFIER}.Action-Extension"],
readonly: true
)
# 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,
issuer_id: APPSTORECONNECT_ISSUER_ID
)
latest_testflight_build_number
# Figure out the build number (and optionally build name)
new_build_number = ( + 1)
# Prep the xcodeproject from Flutter without building (`--config-only`)
sh(
"/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
)
version = get_version_number(xcodeproj: "Runner.xcodeproj", target: 'Runner')
increment_version_number(
version_number: version
)
increment_build_number({
build_number: latest_testflight_build_number + 1
})
# Build & sign using Runner.xcworkspace
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
output_directory: "../build/ios/archive"
)
upload_to_testflight(
# This takes a long time, so don't waste GH runner minutes (but it means manually needing to
# set the build live for external testers).
skip_waiting_for_build_processing: true,
)
end
end

13
ios/fastlane/Matchfile Normal file
View File

@ -0,0 +1,13 @@
git_url("git@github.com:Livinglist/certificates.git")
storage_mode("git")
type("development") # The default type, can be: appstore, adhoc, enterprise or development
# app_identifier(["tools.fastlane.app", "tools.fastlane.app2"])
# username("user@fastlane.tools") # Your Apple Developer Portal username
# For all available options run `fastlane match --help`
# Remove the # in the beginning of the line to enable the other options
# The docs are available on https://docs.fastlane.tools/actions/match

View File

@ -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);
@ -101,7 +101,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,
),

View File

@ -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;

View File

@ -37,6 +37,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);
@ -70,7 +71,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
) async {
_streamSubscription ??=
_preferenceCubit.stream.listen((PreferenceState event) {
final bool isComplexTile = event.showComplexStoryTile;
final bool isComplexTile = event.complexStoryTileEnabled;
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
if (pageSize != state.currentPageSize) {
@ -78,12 +79,17 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}
});
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
final bool isComplexTile = _preferenceCubit.state.showComplexStoryTile;
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
emit(
const StoriesState.init().copyWith(
offlineReading: hasCachedStories,
offlineReading: 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) {
@ -296,12 +302,30 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}
}
Future<void> onCancelDownload(
StoriesCancelDownload event,
Emitter<StoriesState> emit,
) async {
emit(
state.copyWith(
downloadStatus: StoriesDownloadStatus.canceled,
),
);
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
}
Future<void> fetchAndCacheStories(
Iterable<int> ids, {
required bool includingWebPage,
required bool isPrioritized,
}) async {
for (final int id in ids) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) break;
_logger.d('fetching story $id');
final Story? story = await _storiesRepository.fetchStoryBy(id);
if (story == null) {
@ -329,11 +353,13 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
.fetchAllChildrenComments(ids: story.kids)
.whereType<Comment>()
.listen(
(Comment comment) => unawaited(
_offlineRepository.cacheComment(comment: comment),
),
)
.onDone(() => add(StoryDownloaded(skipped: false)));
(Comment comment) {
_logger.d('fetched comment ${comment.id}');
unawaited(
_offlineRepository.cacheComment(comment: comment),
);
},
).onDone(() => add(StoryDownloaded(skipped: false)));
}
}
@ -374,7 +400,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesPageSizeChanged event,
Emitter<StoriesState> emit,
) async {
emit(const StoriesState.init());
add(StoriesInitialize());
}

View File

@ -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});

View File

@ -11,6 +11,7 @@ enum StoriesDownloadStatus {
downloading,
finished,
failure,
canceled,
}
class StoriesState extends Equatable {
@ -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,7 +56,6 @@ class StoriesState extends Equatable {
StoryType.latest: 0,
StoryType.ask: 0,
StoryType.show: 0,
StoryType.jobs: 0,
},
}) : offlineReading = false,
downloadStatus = StoriesDownloadStatus.initial,

View File

@ -1,3 +1,5 @@
import 'package:hacki/extensions/extensions.dart';
abstract class Constants {
static const String endUserAgreementLink =
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45';
@ -10,6 +12,11 @@ abstract class Constants {
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 supportEmail = 'georgefung98@gmail.com';
static const String _imagePath = 'assets/images';
static const String hackerNewsLogoPath = '$_imagePath/hacker_news_logo.png';
@ -20,6 +27,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 +36,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 +55,12 @@ abstract class Constants {
'(ㆆ_ㆆ)',
'ʕ•́ᴥ•̀ʔっ',
'(ㆆ_ㆆ)',
];
].pickRandomly()!;
static final String errorMessage = 'Something went wrong...$sadFace';
}
abstract class RegExpConstants {
static const String linkSuffix = r'(\)|])(.)*$';
static const String number = '[0-9]+';
}

View File

@ -2,8 +2,7 @@ import 'package:logger/logger.dart';
class CustomLogFilter extends LogFilter {
@override
// ignore: overridden_fields
Level? level = Level.verbose;
Level? get level => Level.verbose;
/// The minimal level allowed in production.
static const Level _minimalLevel = Level.info;

View File

@ -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),
),
),
);

View 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();
}
}

View File

@ -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())

View File

@ -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,
),
);
}

View File

@ -11,6 +11,7 @@ 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:logger/logger.dart';
part 'comments_state.dart';
@ -21,6 +22,7 @@ class CommentsCubit extends Cubit<CommentsState> {
OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository,
SembastRepository? sembastRepository,
Logger? logger,
required bool offlineReading,
required Item item,
required FetchMode defaultFetchMode,
@ -33,6 +35,7 @@ class CommentsCubit extends Cubit<CommentsState> {
storiesRepository ?? locator.get<StoriesRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(
CommentsState.init(
offlineReading: offlineReading,
@ -47,8 +50,17 @@ class CommentsCubit extends Cubit<CommentsState> {
final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository;
final Logger _logger;
/// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story.
StreamSubscription<Comment>? _streamSubscription;
/// The map of [StreamSubscription] for streams
/// fetching comments lazily. [int] is the id of parent comment.
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _pageSize = 20;
@override
@ -68,12 +80,12 @@ class CommentsCubit extends Cubit<CommentsState> {
state.copyWith(
comments: targetParents,
onlyShowTargetComment: true,
status: CommentsStatus.loaded,
status: CommentsStatus.allLoaded,
),
);
_streamSubscription = _storiesRepository
.fetchAllCommentsStream(
.fetchAllCommentsRecursivelyStream(
ids: targetParents!.last.kids,
level: targetParents.last.level + 1,
)
@ -105,31 +117,40 @@ class CommentsCubit extends Cubit<CommentsState> {
.listen(_onCommentFetched)
..onDone(_onDone);
} else {
if (state.fetchMode == FetchMode.lazy) {
_streamSubscription = _storiesRepository
.fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
} else {
_streamSubscription = _storiesRepository
.fetchAllCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
switch (state.fetchMode) {
case FetchMode.lazy:
_streamSubscription = _storiesRepository
.fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
break;
case FetchMode.eager:
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
break;
}
}
}
Future<void> refresh() async {
emit(
state.copyWith(
status: CommentsStatus.loading,
),
);
if (state.offlineReading) {
emit(
state.copyWith(
status: CommentsStatus.loaded,
status: CommentsStatus.allLoaded,
),
);
return;
@ -137,16 +158,19 @@ class CommentsCubit extends Cubit<CommentsState> {
_collapseCache.resetCollapsedComments();
await _streamSubscription?.cancel();
for (final int id in _streamSubscriptions.keys) {
await _streamSubscriptions[id]?.cancel();
}
_streamSubscriptions.clear();
emit(
state.copyWith(
status: CommentsStatus.loading,
comments: <Comment>[],
currentPage: 0,
),
);
await _streamSubscription?.cancel();
final Item item = state.item;
final Item updatedItem =
await _storiesRepository.fetchItemBy(id: item.id) ?? item;
@ -161,7 +185,7 @@ class CommentsCubit extends Cubit<CommentsState> {
..onDone(_onDone);
} else {
_streamSubscription = _storiesRepository
.fetchAllCommentsStream(
.fetchAllCommentsRecursivelyStream(
ids: kids,
)
.listen(_onCommentFetched)
@ -171,7 +195,6 @@ class CommentsCubit extends Cubit<CommentsState> {
emit(
state.copyWith(
item: updatedItem,
status: CommentsStatus.loaded,
),
);
}
@ -189,20 +212,22 @@ class CommentsCubit extends Cubit<CommentsState> {
/// [comment] is only used for lazy fetching.
void loadMore({Comment? comment}) {
if (state.fetchMode == FetchMode.eager) {
if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading));
_streamSubscription?.resume();
}
} else {
if (comment == null) return;
if (comment == null && state.status == CommentsStatus.loading) return;
final int level = comment.level + 1;
int offset = 0;
switch (state.fetchMode) {
case FetchMode.lazy:
if (comment == null) return;
if (_streamSubscriptions.containsKey(comment.id)) return;
_streamSubscription = _streamSubscription =
_storiesRepository.fetchCommentsStream(ids: comment.kids).listen(
(Comment cmt) {
final int level = comment.level + 1;
int offset = 0;
/// Ignoring because the subscription will be cancelled in close()
// ignore: cancel_subscriptions
final StreamSubscription<Comment> streamSubscription =
_storiesRepository
.fetchCommentsStream(ids: comment.kids)
.listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt);
@ -223,8 +248,25 @@ class CommentsCubit extends Cubit<CommentsState> {
),
);
offset++;
},
);
})
..onDone(() {
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
})
..onError((dynamic error) {
_logger.e(error);
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
});
_streamSubscriptions[comment.id] = streamSubscription;
break;
case FetchMode.eager:
if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading));
_streamSubscription?.resume();
}
break;
}
}
@ -255,6 +297,10 @@ class CommentsCubit extends Cubit<CommentsState> {
if (state.order == order) return;
HapticFeedback.selectionClick();
_streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
s.cancel();
}
_streamSubscriptions.clear();
emit(state.copyWith(order: order));
init(useCommentCache: true);
}
@ -265,6 +311,10 @@ class CommentsCubit extends Cubit<CommentsState> {
_collapseCache.resetCollapsedComments();
HapticFeedback.selectionClick();
_streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
s.cancel();
}
_streamSubscriptions.clear();
emit(state.copyWith(fetchMode: fetchMode));
init(useCommentCache: true);
}
@ -319,12 +369,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,
),
);
}
@ -360,6 +415,9 @@ class CommentsCubit extends Cubit<CommentsState> {
@override
Future<void> close() async {
await _streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
await s.cancel();
}
await super.close();
}
}

View File

@ -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,
@ -78,10 +67,11 @@ class CommentsState extends Equatable {
);
}
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
@override
List<Object?> get props => <Object?>[
item,
comments,
status,
fetchParentStatus,
order,
@ -89,5 +79,6 @@ class CommentsState extends Equatable {
onlyShowTargetComment,
offlineReading,
currentPage,
comments,
];
}

View File

@ -13,6 +13,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';

View File

@ -1,13 +1,14 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/debouncer.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
part 'edit_state.dart';
class EditCubit extends Cubit<EditState> {
class EditCubit extends HydratedCubit<EditState> {
EditCubit({DraftCache? draftCache})
: _draftCache = draftCache ?? locator.get<DraftCache>(),
_debouncer = Debouncer(delay: const Duration(seconds: 1)),
@ -47,6 +48,7 @@ class EditCubit extends Cubit<EditState> {
_draftCache.removeDraft(replyingTo: state.replyingTo!.id);
}
emit(const EditState.init());
clear();
}
void onTextChanged(String text) {
@ -61,4 +63,54 @@ class EditCubit extends Cubit<EditState> {
});
}
}
void deleteDraft() => clear();
bool called = false;
@override
EditState? fromJson(Map<String, dynamic> json) {
final String text = json['text'] as String? ?? '';
final Map<String, dynamic>? itemJson =
json['item'] as Map<String, dynamic>?;
final Item? replyingTo = itemJson == null ? null : Item.fromJson(itemJson);
if (replyingTo != null && text.isNotEmpty) {
_draftCache.cacheDraft(text: text, replyingTo: replyingTo.id);
final EditState state = EditState(
text: text,
replyingTo: replyingTo,
);
_cachedState = state;
return state;
}
return state;
}
@override
Map<String, dynamic>? toJson(EditState state) {
EditState selected = state;
if (state.replyingTo == null ||
(state.replyingTo?.id != _cachedState.replyingTo?.id &&
state.text.isNullOrEmpty)) {
selected = _cachedState;
}
if (selected.text.isNullOrEmpty) {
clear();
return null;
}
return <String, dynamic>{
'text': selected.text ?? '',
'item': selected.replyingTo?.toJson(),
};
}
static EditState _cachedState = const EditState.init();
}

View File

@ -42,9 +42,9 @@ class FavState extends Equatable {
@override
List<Object?> get props => <Object?>[
favIds,
favItems,
status,
currentPage,
favIds,
favItems,
];
}

View File

@ -42,9 +42,9 @@ class HistoryState extends Equatable {
@override
List<Object?> get props => <Object?>[
submittedIds,
submittedItems,
status,
currentPage,
submittedIds,
submittedItems,
];
}

View File

@ -30,19 +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) {
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();
}
});
@ -129,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,

View File

@ -77,11 +77,11 @@ class NotificationState extends Equatable {
@override
List<Object?> get props => <Object?>[
comments,
unreadCommentsIds,
allCommentsIds,
currentPage,
offset,
status,
comments,
unreadCommentsIds,
allCommentsIds,
];
}

View File

@ -1,94 +1,78 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.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;
emit(state.copyWith(fetchMode: fetchMode));
_preferenceRepository.selectFetchMode(fetchMode);
}
void selectCommentsOrder(CommentsOrder? order) {
if (order == null || state.order == order) return;
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();
}
}
}

View File

@ -2,78 +2,102 @@ 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 webFirstEnabled => _isOn<NavigationModePreference>();
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);
@override
List<Object?> get props => <Object?>[
showNotification,
showComplexStoryTile,
showWebFirst,
showEyeCandy,
useTrueDark,
useReader,
markReadStories,
showMetadata,
fetchMode,
order,
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),
];
}

View File

@ -23,73 +23,71 @@ class SearchCubit extends Cubit<SearchState> {
state.copyWith(
results: <Story>[],
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(_onStoryFetched)
..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(_onStoryFetched)
..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>() {
emit(
state.copyWith(
searchFilters: state.searchFilters.copyWithFilterRemoved<T>(),
params: state.params.copyWithFilterRemoved<T>(),
),
);
search(state.searchFilters.query);
search(state.params.query);
}
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) {

View File

@ -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();
params = SearchParams.init();
final List<Story> results;
final SearchStatus status;
final SearchFilters searchFilters;
final SearchParams params;
SearchState copyWith({
List<Story>? 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,
];
}

View 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),
);
}
}
}

View 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];
}

View File

@ -10,7 +10,7 @@ class UserCubit extends Cubit<UserState> {
UserCubit({StoriesRepository? storiesRepository})
: _storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
super(UserState.init());
super(const UserState.init());
final StoriesRepository _storiesRepository;

View File

@ -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;

View File

@ -1,7 +1,11 @@
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 TryReadContext on BuildContext {
extension ContextExtension on BuildContext {
T? tryRead<T>() {
try {
return read<T>();
@ -10,10 +14,72 @@ extension TryReadContext 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 =
box == null ? null : box.localToGlobal(Offset.zero) & box.size;
return rect;
}
static double _screenWidth = 0;
static double _storyTileHeight = 0;
static int _storyTileMaxLines = 4;
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 =
min(MediaQuery.of(this).size.height, MediaQuery.of(this).size.width);
if (screenWidth == _screenWidth) {
return _storyTileHeight;
} else {
_screenWidth = screenWidth;
}
final bool showSmallerPreviewPic = screenWidth > _screenWidthLowerBound &&
screenWidth < _screenWidthUpperBound;
final double height = showSmallerPreviewPic
? _smallPicHeight
: (screenWidth * _picHeightFactor)
.clamp(_picHeightLowerBound, _picHeightUpperBound);
final int maxLines = height == _smallPicHeight ? 3 : 4;
_storyTileMaxLines = maxLines;
_storyTileHeight = height;
return height;
}
int get storyTileMaxLines {
return _storyTileMaxLines;
}
}

View File

@ -7,10 +7,8 @@ extension DateTimeExtension on DateTime {
return '$gap year${gap == 1 ? '' : 's'} ago';
} else if (diff.inDays > 30) {
int gap = now.month - month;
if (gap == 0) {
gap = 1;
} else if (gap < 0) {
gap = now.month + (12 - month);
if (gap <= 0) {
gap = now.month + 12 - month;
}
return '$gap month${gap == 1 ? '' : 's'} ago';
} else if (diff.inDays >= 1) {

View File

@ -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';

View File

@ -1,7 +1,8 @@
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);
locator.get<Logger>().d('$identifier ${toString()}');
}
}

View 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;
}
}

View File

@ -1,9 +1,19 @@
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/config/locator.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/repositories/repositories.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:hacki/utils/utils.dart';
import 'package:share_plus/share_plus.dart';
extension StateExtension on State {
void showSnackBar({
@ -11,22 +21,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 +47,202 @@ 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,
builder: (BuildContext context) {
return MorePopupMenu(
item: item,
isBlocked: isBlocked,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
);
},
).then((MenuAction? action) {
if (action != null) {
switch (action) {
case MenuAction.upvote:
break;
case MenuAction.downvote:
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;
}
}
});
}
Future<void> onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
await locator
.get<StoriesRepository>()
.fetchItemBy(id: id)
.then((Item? item) {
if (mounted) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
}
});
} else {
LinkUtil.launch(link);
}
}
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 Container(
height: 140,
color: Theme.of(context).canvasColor,
child: Material(
child: Column(
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() {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return LoginDialog(
usernameController: usernameController,
passwordController: passwordController,
showSnackBar: showSnackBar,
);
},
);
}
}

View File

@ -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])',
@ -13,3 +17,12 @@ extension StringExtension on String {
return replaceAllMapped(regex, (_) => '');
}
}
extension OptionalStringExtension on String? {
bool get isNullOrEmpty {
if (this == null) return true;
return this!.trim().isEmpty;
}
bool get isNotNullOrEmpty => !isNullOrEmpty;
}

View File

@ -2,10 +2,12 @@ import 'dart:async';
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
@ -13,12 +15,14 @@ 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: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';
@ -34,11 +38,36 @@ 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
: await getTemporaryDirectory(),
);
if (Platform.isIOS) {
unawaited(
Workmanager().initialize(
@ -50,8 +79,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,
@ -59,7 +88,8 @@ Future<void> main({bool testing = false}) async {
);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onSelectNotification: selectNotificationSubject.add,
onDidReceiveBackgroundNotificationResponse: notificationReceiver,
onDidReceiveNotificationResponse: notificationReceiver,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
@ -79,39 +109,36 @@ Future<void> main({bool testing = false}) async {
siriSuggestionSubject.add(storyId);
},
);
} else if (Platform.isAndroid) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Palette.transparent,
systemNavigationBarColor: Palette.transparent,
systemNavigationBarDividerColor: Palette.transparent,
),
);
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
overlays: <SystemUiOverlay>[SystemUiOverlay.top],
);
}
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;
if (kReleaseMode) {
runApp(
HackiApp(
savedThemeMode: savedThemeMode,
trueDarkMode: trueDarkMode,
),
);
} else {
BlocOverrides.runZoned(
() {
runApp(
HackiApp(
savedThemeMode: savedThemeMode,
trueDarkMode: trueDarkMode,
),
);
},
blocObserver: CustomBlocObserver(),
);
}
Bloc.observer = CustomBlocObserver();
HydratedBloc.storage = storage;
runApp(
HackiApp(
savedThemeMode: savedThemeMode,
trueDarkMode: trueDarkMode,
),
);
}
class HackiApp extends StatelessWidget {
@ -187,6 +214,15 @@ class HackiApp extends StatelessWidget {
lazy: false,
create: (BuildContext context) => PostCubit(),
),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
BlocProvider<TabCubit>(
create: (BuildContext context) => TabCubit(
preferenceCubit: context.read<PreferenceCubit>(),
)..init(),
)
],
child: AdaptiveTheme(
light: ThemeData(
@ -214,9 +250,9 @@ class HackiApp extends StatelessWidget {
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

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:hacki/models/item.dart';
class Comment extends Item {
@ -22,23 +20,7 @@ class Comment extends Item {
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: '',
);
Comment.fromJson(super.json, {this.level = 0}) : super.fromJson();
final int level;
@ -59,6 +41,7 @@ class Comment extends Item {
);
}
@override
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'time': time,
@ -73,11 +56,7 @@ class Comment extends Item {
};
@override
String toString() {
final String prettyString =
const JsonEncoder.withIndent(' ').convert(this);
return 'Comment $prettyString';
}
bool? get stringify => false;
@override
List<Object?> get props => <Object?>[

View File

@ -0,0 +1,9 @@
enum CommentsOrder {
natural('Natural'),
newestFirst('Newest first'),
oldestFirst('Oldest first');
const CommentsOrder(this.description);
final String description;
}

View 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;
}

View File

@ -0,0 +1,8 @@
enum FetchMode {
lazy('Lazy'),
eager('Eager');
const FetchMode(this.description);
final String description;
}

13
lib/models/font_size.dart Normal file
View 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;
}

View File

@ -1,7 +1,7 @@
import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.dart';
abstract class Item extends Equatable {
class Item extends Equatable {
const Item({
required this.id,
required this.deleted,
@ -35,6 +35,22 @@ abstract class Item extends Equatable {
text = '',
type = '';
Item.fromJson(Map<String, dynamic> json)
: id = json['id'] as int? ?? 0,
score = json['score'] as int? ?? 0,
descendants = json['descendants'] as int? ?? 0,
time = json['time'] as int? ?? 0,
by = json['by'] as String? ?? '',
title = json['title'] as String? ?? '',
text = json['text'] as String? ?? '',
url = json['url'] as String? ?? '',
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 = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
type = json['type'] as String? ?? '';
final int id;
final int time;
final int score;
@ -65,4 +81,40 @@ abstract class Item extends Equatable {
bool get isJob => type == 'job';
bool get isComment => type == 'comment';
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
List<Object?> get props => <Object?>[
id,
deleted,
by,
time,
text,
dead,
parent,
kids,
url,
score,
title,
type,
parts,
descendants,
];
}

View File

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

View File

@ -24,41 +24,11 @@ class PollOption extends Item {
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: '',
);
super.empty();
PollOption.fromJson(Map<String, dynamic> json)
PollOption.fromJson(super.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,
);
super.fromJson();
final double ratio;
@ -79,6 +49,7 @@ class PollOption extends Item {
);
}
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'descendants': descendants,

356
lib/models/preference.dart Normal file
View File

@ -0,0 +1,356 @@
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 first four preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
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(),
NavigationModePreference(),
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 _navigationModeDefaultValueIOS = false;
const bool _navigationModeDefaultValueAndroid = false;
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 _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.''';
}
/// The value deciding whether or not user should be
/// navigated to web view first. Defaults to false.
class NavigationModePreference extends BooleanPreference {
NavigationModePreference({bool? val})
: super(
val: val ??
(Platform.isAndroid
? _navigationModeDefaultValueAndroid
: _navigationModeDefaultValueIOS),
);
@override
NavigationModePreference copyWith({required bool? val}) {
return NavigationModePreference(val: val);
}
@override
String get key => 'navigationMode';
@override
String get title => 'Show Web Page First';
@override
String get subtitle => '''show web page first after tapping on story.''';
}
class ReaderModePreference extends BooleanPreference {
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 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';
}

View File

@ -1,4 +1,4 @@
part of 'search_filters.dart';
part of 'search_params.dart';
abstract class SearchFilter {
String get query;

View File

@ -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,

View File

@ -1,35 +1,6 @@
import 'package:hacki/config/constants.dart';
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,
@ -49,15 +20,17 @@ class Story extends Item {
parent: 0,
);
Story.empty()
Story.empty() : super.empty();
Story.placeholder()
: super(
id: 0,
score: 0,
descendants: 0,
time: 0,
by: '',
title: '',
url: '',
time: 1171872000,
by: 'Y Combinator',
title: 'Hacker News Guidelines',
url: Constants.guidelineLink,
kids: <int>[],
dead: false,
parts: <int>[],
@ -67,23 +40,7 @@ class Story extends Item {
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,
);
Story.fromJson(super.json) : super.fromJson();
String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $postedDate | $descendants comment${descendants > 1 ? 's' : ''}''';
@ -91,6 +48,13 @@ class Story extends Item {
String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate''';
String get readableUrl {
final Uri url = Uri.parse(this.url);
final String authority = url.authority.replaceFirst('www.', '');
return authority;
}
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'descendants': descendants,

View 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'),
);
}
}

View File

@ -1,9 +1,8 @@
import 'dart:convert';
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,
@ -11,7 +10,7 @@ class User {
required this.karma,
});
User.empty()
const User.empty()
: about = '',
created = 0,
delay = 0,
@ -39,8 +38,15 @@ class User {
@override
String toString() {
final String prettyString =
const JsonEncoder.withIndent(' ').convert(this);
return 'User $prettyString';
return 'User $about, $created, $delay, $id, $karma';
}
@override
List<Object?> get props => <Object?>[
about,
created,
delay,
id,
karma,
];
}

View File

@ -1,27 +1,23 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.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';
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 +28,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 +62,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 +79,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 +96,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 +113,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(

View File

@ -8,22 +8,19 @@ import 'package:hacki/repositories/preference_repository.dart';
import 'package:hacki/utils/utils.dart';
class PostRepository extends PostableRepository {
PostRepository({Dio? dio, PreferenceRepository? storageRepository})
PostRepository({super.dio, PreferenceRepository? storageRepository})
: _preferenceRepository =
storageRepository ?? locator.get<PreferenceRepository>(),
super(dio: dio);
storageRepository ?? locator.get<PreferenceRepository>();
final PreferenceRepository _preferenceRepository;
static const String _authority = 'news.ycombinator.com';
Future<bool> comment({
required int parentId,
required String text,
}) async {
final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password;
final Uri uri = Uri.https(_authority, 'comment');
final Uri uri = Uri.https(authority, 'comment');
if (username == null || password == null) {
return false;
@ -55,7 +52,7 @@ class PostRepository extends PostableRepository {
return false;
}
final Response<List<int>> formResponse = await _getFormResponse(
final Response<List<int>> formResponse = await getFormResponse(
username: username,
password: password,
path: 'submitlink',
@ -70,7 +67,7 @@ class PostRepository extends PostableRepository {
final String? cookie =
formResponse.headers.value(HttpHeaders.setCookieHeader);
final Uri uri = Uri.https(_authority, 'r');
final Uri uri = Uri.https(authority, 'r');
final PostDataMixin data = SubmitPostData(
fnid: formValues['fnid']!,
fnop: formValues['fnop']!,
@ -98,7 +95,7 @@ class PostRepository extends PostableRepository {
return false;
}
final Response<List<int>> formResponse = await _getFormResponse(
final Response<List<int>> formResponse = await getFormResponse(
username: username,
password: password,
id: id,
@ -114,7 +111,7 @@ class PostRepository extends PostableRepository {
final String? cookie =
formResponse.headers.value(HttpHeaders.setCookieHeader);
final Uri uri = Uri.https(_authority, 'xedit');
final Uri uri = Uri.https(authority, 'xedit');
final PostDataMixin data = EditPostData(
hmac: formValues['hmac']!,
id: id,
@ -127,28 +124,4 @@ class PostRepository extends PostableRepository {
cookie: cookie,
);
}
Future<Response<List<int>>> _getFormResponse({
required String username,
required String password,
required String path,
int? id,
}) async {
final Uri uri = Uri.https(
_authority,
path,
<String, dynamic>{if (id != null) 'id': id.toString()},
);
final PostDataMixin data = FormPostData(
acct: username,
pw: password,
id: id,
);
return performPost(
uri,
data,
responseType: ResponseType.bytes,
validateStatus: (int? status) => status == HttpStatus.ok,
);
}
}

View File

@ -8,10 +8,14 @@ import 'package:hacki/utils/service_exception.dart';
class PostableRepository {
PostableRepository({
Dio? dio,
this.authority = 'news.ycombinator.com',
}) : _dio = dio ?? Dio();
final Dio _dio;
@protected
final String authority;
@protected
Future<bool> performDefaultPost(
Uri uri,
@ -60,4 +64,29 @@ class PostableRepository {
throw ServiceException(e.message);
}
}
@protected
Future<Response<List<int>>> getFormResponse({
required String username,
required String password,
required String path,
int? id,
}) async {
final Uri uri = Uri.https(
authority,
path,
<String, dynamic>{if (id != null) 'id': id.toString()},
);
final PostDataMixin data = FormPostData(
acct: username,
pw: password,
id: id,
);
return performPost(
uri,
data,
responseType: ResponseType.bytes,
validateStatus: (int? status) => status == HttpStatus.ok,
);
}
}

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/comments/comments_cubit.dart';
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:synced_shared_preferences/synced_shared_preferences.dart';
@ -25,39 +24,6 @@ class PreferenceRepository {
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
static const String _lastReadStoryIdKey = 'lastReadStoryId';
static const String _isFirstLaunchKey = 'isFirstLaunch';
static const String _metadataModeKey = 'metadataMode';
static const String _notificationModeKey = 'notificationMode';
static const String _readerModeKey = 'readerMode';
/// Exposing this val for main func.
static const String trueDarkModeKey = 'trueDarkMode';
/// The key of a boolean value deciding whether or not the story
/// tile should display link preview. Defaults to true.
static const String _displayModeKey = 'displayMode';
/// The key of a boolean value deciding whether or not user should be
/// navigated to web view first. Defaults to false.
static const String _navigationModeKey = 'navigationMode';
static const String _eyeCandyModeKey = 'eyeCandyMode';
static const String _markReadStoriesModeKey = 'markReadStoriesMode';
static const String _fetchModeKey = 'fetchMode';
static const String _commentsOrderKey = 'commentsOrder';
static const bool _notificationModeDefaultValue = true;
static const bool _displayModeDefaultValue = true;
static const bool _navigationModeDefaultValueIOS = true;
static const bool _navigationModeDefaultValueAndroid = false;
static const bool _eyeCandyModeDefaultValue = false;
static const bool _trueDarkModeDefaultValue = false;
static const bool _readerModeDefaultValue = true;
static const bool _markReadStoriesModeDefaultValue = true;
static const bool _isFirstLaunchKeyDefaultValue = true;
static const bool _metadataModeDefaultValue = true;
static final int _fetchModeDefaultValue = FetchMode.eager.index;
static final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final SyncedSharedPreferences _syncedPrefs;
final Future<SharedPreferences> _prefs;
@ -70,70 +36,21 @@ class PreferenceRepository {
Future<String?> get password async => _secureStorage.read(key: _passwordKey);
Future<bool> get isFirstLaunch async {
final SharedPreferences prefs = await _prefs;
final bool val =
prefs.getBool(_isFirstLaunchKey) ?? _isFirstLaunchKeyDefaultValue;
await prefs.setBool(_isFirstLaunchKey, false);
return val;
}
Future<bool> get shouldShowNotification async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getBool(_notificationModeKey) ??
_notificationModeDefaultValue,
Future<bool?> getBool(String key) => _prefs.then(
(SharedPreferences prefs) => prefs.getBool(key),
);
Future<bool> get shouldShowComplexStoryTile async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getBool(_displayModeKey) ?? _displayModeDefaultValue,
Future<int?> getInt(String key) => _prefs.then(
(SharedPreferences prefs) => prefs.getInt(key),
);
Future<bool> get shouldShowWebFirst async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getBool(_navigationModeKey) ??
(Platform.isAndroid
? _navigationModeDefaultValueAndroid
: _navigationModeDefaultValueIOS),
//ignore: avoid_positional_boolean_parameters
void setBool(String key, bool val) => _prefs.then(
(SharedPreferences prefs) => prefs.setBool(key, val),
);
Future<bool> get shouldShowEyeCandy async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getBool(_eyeCandyModeKey) ?? _eyeCandyModeDefaultValue,
);
Future<bool> get shouldShowMetadata async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getBool(_metadataModeKey) ?? _metadataModeDefaultValue,
);
Future<bool> get trueDarkMode async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getBool(trueDarkModeKey) ?? _trueDarkModeDefaultValue,
);
Future<bool> get readerMode async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getBool(_readerModeKey) ?? _readerModeDefaultValue,
);
Future<bool> get markReadStories async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getBool(_markReadStoriesModeKey) ??
_markReadStoriesModeDefaultValue,
);
Future<FetchMode> get fetchMode async => _prefs.then(
(SharedPreferences prefs) => FetchMode.values
.elementAt(prefs.getInt(_fetchModeKey) ?? _fetchModeDefaultValue),
);
Future<CommentsOrder> get commentsOrder async => _prefs.then(
(SharedPreferences prefs) => CommentsOrder.values.elementAt(
prefs.getInt(_commentsOrderKey) ?? _commentsOrderDefaultValue,
),
void setInt(String key, int val) => _prefs.then(
(SharedPreferences prefs) => prefs.setInt(key, val),
);
Future<bool> hasPushed(int commentId) async =>
@ -195,76 +112,6 @@ class PreferenceRepository {
await _secureStorage.delete(key: _passwordKey);
}
Future<void> toggleNotificationMode() async {
final SharedPreferences prefs = await _prefs;
final bool currentMode =
prefs.getBool(_notificationModeKey) ?? _notificationModeDefaultValue;
await prefs.setBool(_notificationModeKey, !currentMode);
}
Future<void> toggleDisplayMode() async {
final SharedPreferences prefs = await _prefs;
final bool currentMode =
prefs.getBool(_displayModeKey) ?? _displayModeDefaultValue;
await prefs.setBool(_displayModeKey, !currentMode);
}
Future<void> toggleNavigationMode() async {
final SharedPreferences prefs = await _prefs;
final bool currentMode = prefs.getBool(_navigationModeKey) ??
(Platform.isAndroid
? _navigationModeDefaultValueAndroid
: _navigationModeDefaultValueIOS);
await prefs.setBool(_navigationModeKey, !currentMode);
}
Future<void> toggleEyeCandyMode() async {
final SharedPreferences prefs = await _prefs;
final bool currentMode =
prefs.getBool(_eyeCandyModeKey) ?? _eyeCandyModeDefaultValue;
await prefs.setBool(_eyeCandyModeKey, !currentMode);
}
Future<void> toggleTrueDarkMode() async {
final SharedPreferences prefs = await _prefs;
final bool currentMode =
prefs.getBool(trueDarkModeKey) ?? _trueDarkModeDefaultValue;
await prefs.setBool(trueDarkModeKey, !currentMode);
}
Future<void> toggleReaderMode() async {
final SharedPreferences prefs = await _prefs;
final bool currentMode =
prefs.getBool(_readerModeKey) ?? _readerModeDefaultValue;
await prefs.setBool(_readerModeKey, !currentMode);
}
Future<void> toggleMarkReadStoriesMode() async {
final SharedPreferences prefs = await _prefs;
final bool currentMode = prefs.getBool(_markReadStoriesModeKey) ??
_markReadStoriesModeDefaultValue;
await prefs.setBool(_markReadStoriesModeKey, !currentMode);
}
Future<void> toggleMetadataMode() async {
final SharedPreferences prefs = await _prefs;
final bool currentMode =
prefs.getBool(_metadataModeKey) ?? _metadataModeDefaultValue;
await prefs.setBool(_metadataModeKey, !currentMode);
}
Future<void> selectFetchMode(FetchMode fetchMode) async {
final SharedPreferences prefs = await _prefs;
final int index = fetchMode.index;
await prefs.setInt(_fetchModeKey, index);
}
Future<void> selectCommentsOrder(CommentsOrder order) async {
final SharedPreferences prefs = await _prefs;
final int index = order.index;
await prefs.setInt(_commentsOrderKey, index);
}
//#region fav
Future<List<int>> favList({required String of}) async {

View File

@ -11,9 +11,9 @@ class SearchRepository {
final Dio _dio;
Stream<Story> search({
required SearchFilters filters,
required SearchParams params,
}) async* {
final String url = '$_baseUrl${filters.filteredQuery}';
final String url = '$_baseUrl${params.filteredQuery}';
final Response<Map<String, dynamic>> response =
await _dio.get<Map<String, dynamic>>(url);
final Map<String, dynamic>? data = response.data;

View File

@ -73,7 +73,7 @@ class StoriesRepository {
return;
}
Stream<Comment> fetchAllCommentsStream({
Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
@ -94,7 +94,7 @@ class StoriesRepository {
if (comment != null) {
yield comment;
yield* fetchAllCommentsStream(
yield* fetchAllCommentsRecursivelyStream(
ids: comment.kids,
level: level + 1,
getFromCache: getFromCache,

View File

@ -0,0 +1,337 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.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/repositories/repositories.dart';
import 'package:hacki/screens/home/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
static const String routeName = '/';
static Route<dynamic> route() {
return MaterialPageRoute<HomeScreen>(
settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => const HomeScreen(),
);
}
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware {
late final TabController tabController;
late final StreamSubscription<String> intentDataStreamSubscription;
late final StreamSubscription<String?> notificationStreamSubscription;
late final StreamSubscription<String?> siriSuggestionStreamSubscription;
static final int tabLength = StoryType.values.length + 1;
@override
void didPopNext() {
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed(
const Duration(milliseconds: 500),
locator.get<CommentCache>().resetComments,
);
}
}
@override
void initState() {
super.initState();
// This is for testing only.
// FeatureDiscovery.clearPreferences(context, <String>[
// Constants.featureLogIn,
// Constants.featureAddStoryToFavList,
// Constants.featureOpenStoryInWebView,
// Constants.featurePinToTop,
// ]);
ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped);
intentDataStreamSubscription =
ReceiveSharingIntent.getTextStream().listen(onShareExtensionTapped);
if (!selectNotificationSubject.hasListener) {
notificationStreamSubscription =
selectNotificationSubject.stream.listen(onNotificationTapped);
}
if (!siriSuggestionSubject.hasListener) {
siriSuggestionStreamSubscription =
siriSuggestionSubject.stream.listen(onSiriSuggestionTapped);
}
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (!isTesting) {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featureLogIn,
},
);
}
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
if (route == null) return;
locator
.get<RouteObserver<ModalRoute<dynamic>>>()
.subscribe(this, route);
});
tabController = TabController(length: tabLength, vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final DeviceScreenType deviceType =
getDeviceType(MediaQuery.of(context).size);
if (context.read<StoriesBloc>().deviceScreenType != deviceType) {
context.read<StoriesBloc>().deviceScreenType = deviceType;
context.read<StoriesBloc>().add(StoriesInitialize());
}
}
@override
void dispose() {
tabController.dispose();
intentDataStreamSubscription.cancel();
notificationStreamSubscription.cancel();
siriSuggestionStreamSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final BlocBuilder<PreferenceCubit, PreferenceState> homeScreen =
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
previous.metadataEnabled != current.metadataEnabled ||
previous.swipeGestureEnabled != current.swipeGestureEnabled,
builder: (BuildContext context, PreferenceState preferenceState) {
return DefaultTabController(
length: tabLength,
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: PreferredSize(
preferredSize: const Size(
Dimens.zero,
Dimens.pt40,
),
child: Column(
children: <Widget>[
SizedBox(
height: MediaQuery.of(context).padding.top - Dimens.pt8,
),
Theme(
data: ThemeData(
highlightColor: Palette.transparent,
splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor,
),
child: CustomTabBar(
tabController: tabController,
),
),
],
),
),
body: BlocBuilder<TabCubit, TabState>(
builder: (BuildContext context, TabState state) {
return TabBarView(
physics: preferenceState.swipeGestureEnabled
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics(),
controller: tabController,
children: <Widget>[
for (final StoryType type in state.tabs)
StoriesListView(
key: ValueKey<StoryType>(type),
storyType: type,
header: PinnedStories(
preferenceState: preferenceState,
onStoryTapped: onStoryTapped,
),
onStoryTapped: onStoryTapped,
),
const ProfileScreen(),
],
);
},
),
),
);
},
);
return ScreenTypeLayout.builder(
mobile: (BuildContext context) {
context.read<SplitViewCubit>().disableSplitView();
return MobileHomeScreen(
homeScreen: homeScreen,
);
},
tablet: (BuildContext context) => TabletHomeScreen(
homeScreen: homeScreen,
),
);
}
void onStoryTapped(Story story, {bool isPin = false}) {
final bool showWebFirst =
context.read<PreferenceCubit>().state.webFirstEnabled;
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
final bool offlineReading =
context.read<StoriesBloc>().state.offlineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
// If a story is a job story and it has a link to the job posting,
// it would be better to just navigate to the web page.
final bool isJobWithLink = story.isJob && story.url.isNotEmpty;
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} else {
final ItemScreenArgs args = ItemScreenArgs(item: story);
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
if (splitViewEnabled) {
context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else {
HackiApp.navigatorKey.currentState
?.pushNamed(
ItemScreen.routeName,
arguments: args,
)
.whenComplete(() {
context.read<ReminderCubit>().removeLastReadStoryId();
});
}
}
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) {
LinkUtil.launch(
story.url,
useReader: useReader,
offlineReading: offlineReading,
);
}
context.read<StoriesBloc>().add(
StoryRead(
story: story,
),
);
if (Platform.isIOS) {
FlutterSiriSuggestions.instance.registerActivity(
FlutterSiriActivity(
story.title,
story.id.toString(),
suggestedInvocationPhrase: '',
contentDescription: story.text,
persistentIdentifier: story.id.toString(),
),
);
}
}
void onShareExtensionTapped(String? event) {
if (event == null) return;
final int? id = event.itemId;
if (id != null) {
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
goToItemScreen(
args: ItemScreenArgs(item: item),
forceNewScreen: true,
);
}
}
});
}
}
Future<void> onSiriSuggestionTapped(String? id) async {
if (id == null) return;
final int? storyId = int.tryParse(id);
if (storyId == null) return;
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.then((Story? story) {
if (story == null) {
showErrorSnackBar();
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
Future<void> onNotificationTapped(String? payload) async {
if (payload == null) return;
final Map<String, dynamic> payloadJson =
jsonDecode(payload) as Map<String, dynamic>;
final int? storyId = payloadJson['storyId'] as int?;
final int? commentId = payloadJson['commentId'] as int?;
if (storyId != null && commentId != null) {
context.read<NotificationCubit>().markAsRead(commentId);
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.then((Story? story) {
if (story == null) {
showErrorSnackBar();
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
}
}

View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class MobileHomeScreen extends StatelessWidget {
const MobileHomeScreen({
super.key,
required this.homeScreen,
});
final Widget homeScreen;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned.fill(child: homeScreen),
if (!context.read<ReminderCubit>().state.hasShown)
const Positioned(
left: Dimens.pt24,
right: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
child: CountdownReminder(),
),
],
);
}
}

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class PinnedStories extends StatelessWidget {
const PinnedStories({
super.key,
required this.preferenceState,
required this.onStoryTapped,
});
final PreferenceState preferenceState;
final void Function(Story story, {bool isPin}) onStoryTapped;
@override
Widget build(BuildContext context) {
return BlocBuilder<PinCubit, PinState>(
builder: (BuildContext context, PinState state) {
return Column(
children: <Widget>[
for (final Story story in state.pinnedStories)
FadeIn(
child: Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
context.read<PinCubit>().unpinStory(story);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: preferenceState.complexStoryTileEnabled
? Icons.close
: null,
label: 'Unpin',
),
],
),
child: ColoredBox(
color: Palette.orangeAccent.withOpacity(0.2),
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
onTap: () => onStoryTapped(story, isPin: true),
showWebPreview: preferenceState.complexStoryTileEnabled,
showMetadata: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,
),
),
),
),
if (state.pinnedStories.isNotEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider(
color: Palette.orangeAccent,
),
),
],
);
},
);
}
}

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