Compare commits
70 Commits
Author | SHA1 | Date | |
---|---|---|---|
fe162208ca | |||
58139ba7a3 | |||
33a31acbe2 | |||
0fcfcbb7e3 | |||
a98f52c90b | |||
8e8e48c44a | |||
603b7cc939 | |||
649fa33df3 | |||
81d4a0f2df | |||
24112a471e | |||
c7824eaef3 | |||
c2b66d29c3 | |||
e0a53e44b2 | |||
4cf8379db0 | |||
c1c26bf0e0 | |||
29e2f4163d | |||
c3de80015d | |||
436cd9ce8b | |||
efb326be68 | |||
047903fe24 | |||
41068ddf89 | |||
196516ce85 | |||
7f647b127d | |||
a50a0874e7 | |||
b176be96fb | |||
1e5af07691 | |||
ecf8c902dc | |||
d3ede8546b | |||
53562ad260 | |||
6c8e7a7cb9 | |||
56c0245335 | |||
0cbd38a530 | |||
7c6da2c36a | |||
185140feb4 | |||
03c01a0b78 | |||
f823fdf241 | |||
fe87ddd8ff | |||
613ba12b05 | |||
8d7f66ecbc | |||
461aae253b | |||
a1b491cf0d | |||
edf0c82040 | |||
946a3c5a9a | |||
d8bc60c071 | |||
48477cd5c8 | |||
38df6293fe | |||
a5fe9e45fc | |||
9de5baa77a | |||
2daccd64e8 | |||
d0c68f9419 | |||
5f1dbfc510 | |||
90eee37c17 | |||
5630e61a74 | |||
eaad4b01dd | |||
3ab172f3d3 | |||
5450eba64b | |||
e2d6bb44d0 | |||
ffbd3a2449 | |||
2405a6d30c | |||
01085e5fd3 | |||
b5e11a72bf | |||
f55bbb6f84 | |||
b3e994269c | |||
a2c66a0075 | |||
5f43fd6968 | |||
d83381a7fd | |||
764ff09345 | |||
ab449adce2 | |||
2ec41b26f2 | |||
19f2107d95 |
23
.github/workflows/commit_check.yml
vendored
Normal 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
|
32
.github/workflows/github-actions.yml
vendored
@ -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
@ -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
@ -0,0 +1,3 @@
|
||||
[submodule "flutter"]
|
||||
path = submodules/flutter
|
||||
url = https://github.com/flutter/flutter
|
1
.ruby-version
Normal file
@ -0,0 +1 @@
|
||||
2.7.5
|
@ -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.
|
||||
|
||||
[](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
|
||||
[](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
|
||||
[](https://github.com/Livinglist/Hacki/releases/latest)
|
||||
[](https://badges.pufler.dev)
|
||||
[](https://img.shields.io/github/stars/livinglist/Hacki?style=social)
|
||||
[](https://pub.dev/packages/effective_dart)
|
||||
[](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/)
|
||||
|
||||
|
@ -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/**"
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 539 B After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.8 KiB |
@ -1,4 +1,4 @@
|
||||
include: package:very_good_analysis/analysis_options.2.4.0.yaml
|
||||
include: package:very_good_analysis/analysis_options.3.1.0.yaml
|
||||
linter:
|
||||
rules:
|
||||
parameter_assignments: false
|
||||
|
@ -1,5 +1,82 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
typealias APNSHandler = ()->Void
|
||||
|
||||
let keyKey = "key"
|
||||
let valKey = "val"
|
||||
|
||||
final class SharedPrefsCore {
|
||||
fileprivate static let shared: SharedPrefsCore = SharedPrefsCore()
|
||||
|
||||
fileprivate func setBool(key: String?, val: Bool?) -> Bool {
|
||||
guard let key = key,
|
||||
let val = val else {
|
||||
return false
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let allVals = keyStore.dictionaryRepresentation;
|
||||
let allKeys = allVals.keys
|
||||
|
||||
// Limit is 1024, reserve rest slots for fav and pins.
|
||||
if allKeys.count >= 1000 {
|
||||
for key in allKeys.filter({ $0.contains("hasRead") }) {
|
||||
keyStore.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
keyStore.set(val, forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
fileprivate func getBool(key: String?) -> Bool {
|
||||
guard let key = key else {
|
||||
return false
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let val = keyStore.bool(forKey: key)
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
fileprivate func setStringList(key: String?, val: [String]?) -> Bool {
|
||||
guard let key = key,
|
||||
let val = val else {
|
||||
return false
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
keyStore.set(val, forKey: key)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fileprivate func getStringList(key: String?) -> [Any] {
|
||||
guard let key = key else {
|
||||
return [Any]()
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let list = keyStore.array(forKey: key) as [Any]? ?? [Any]()
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
fileprivate func clearAll() -> Bool{
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let allVals = keyStore.dictionaryRepresentation;
|
||||
let allKeys = allVals.keys
|
||||
|
||||
for key in allKeys.filter({ $0.contains("hasRead") }) {
|
||||
keyStore.removeObject(forKey: key)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
@ -7,46 +84,49 @@ public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
|
||||
let instance = SwiftSyncedSharedPreferencesPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "setBool":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("setBool"), object: nil, userInfo: info)
|
||||
let val = params[valKey] as? Bool
|
||||
let key = params[keyKey] as? String
|
||||
|
||||
let res = SharedPrefsCore.shared.setBool(key: key, val: val)
|
||||
result(res)
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
case "getBool":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("getBool"), object: nil, userInfo: info)
|
||||
let key = params[keyKey] as? String
|
||||
let res = SharedPrefsCore.shared.getBool(key: key)
|
||||
result(res)
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
case "setStringList":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("setStringList"), object: nil, userInfo: info)
|
||||
let val = params[valKey] as? [String]
|
||||
let key = params[keyKey] as? String
|
||||
|
||||
let res = SharedPrefsCore.shared.setStringList(key: key, val: val)
|
||||
result(res)
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
case "getStringList":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("getStringList"), object: nil, userInfo: info)
|
||||
let key = params[keyKey] as? String
|
||||
let res = SharedPrefsCore.shared.getStringList(key: key)
|
||||
result(res)
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
case "clearAll":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let info: [String: Any] = ["result": result,
|
||||
"params": params]
|
||||
NotificationCenter.default.post(name: Notification.Name("clearAll"), object: nil, userInfo: info)
|
||||
let res = SharedPrefsCore.shared.clearAll()
|
||||
result(res)
|
||||
}
|
||||
|
||||
return
|
||||
|
3
fastlane/metadata/android/en-US/changelogs/70.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Lazy loading.
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
3
fastlane/metadata/android/en-US/changelogs/71.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Lazy loading.
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
3
fastlane/metadata/android/en-US/changelogs/72.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Lazy loading.
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
3
fastlane/metadata/android/en-US/changelogs/73.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Lazy loading.
|
||||
- Offline mode now includes web pages.
|
||||
- You can now sort comments in story screen.
|
2
fastlane/metadata/android/en-US/changelogs/74.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Bumped Flutter version.
|
||||
- Updated navigation bar background color.
|
2
fastlane/metadata/android/en-US/changelogs/75.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Bumped Flutter version.
|
||||
- Updated navigation bar background color.
|
2
fastlane/metadata/android/en-US/changelogs/76.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Fixed app icon.
|
||||
- Added font size setting to comments screen.
|
2
fastlane/metadata/android/en-US/changelogs/77.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Fixed app icon.
|
||||
- Added font size setting to comments screen.
|
1
fastlane/metadata/android/en-US/changelogs/78.txt
Normal file
@ -0,0 +1 @@
|
||||
- Fixed time machine.
|
1
fastlane/metadata/android/en-US/changelogs/79.txt
Normal file
@ -0,0 +1 @@
|
||||
- Fixed time machine.
|
0
fastlane/metadata/android/en-US/changelogs/81.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/84.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Customization of tab bar.
|
||||
- Option to enable swipe gesture for switching between tabs.
|
||||
- Access to action menu from home screen.
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
68
integration_test/scrolling_test.dart
Normal 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',
|
||||
);
|
||||
});
|
||||
}
|
@ -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
@ -0,0 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "cocoapods"
|
285
ios/Gemfile.lock
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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")!)
|
||||
}
|
||||
|
||||
|
@ -1,134 +0,0 @@
|
||||
//
|
||||
// HackiCore.swift
|
||||
// Runner
|
||||
//
|
||||
// Created by Jiaqi Feng on 5/10/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Flutter
|
||||
|
||||
extension Notification.Name {
|
||||
static let setBool = Notification.Name("setBool")
|
||||
static let getBool = Notification.Name("getBool")
|
||||
static let setStringList = Notification.Name("setStringList")
|
||||
static let getStringList = Notification.Name("getStringList")
|
||||
static let clearAll = Notification.Name("clearAll")
|
||||
}
|
||||
|
||||
typealias APNSHandler = ()->Void
|
||||
|
||||
final class HackiCore: NSObject {
|
||||
private static let keyKey = "key"
|
||||
private static let valKey = "val"
|
||||
|
||||
private static let shared: HackiCore = HackiCore()
|
||||
private let notificationCenter = NotificationCenter.default
|
||||
|
||||
// Called at app launch
|
||||
class func start() {
|
||||
shared.registerNotifications()
|
||||
}
|
||||
|
||||
private class func setupFlutterEvent(channelName: String, handler: NSObjectProtocol & FlutterStreamHandler) {
|
||||
guard let rootVC = UIApplication.shared.delegate?.window.unsafelyUnwrapped?.rootViewController as? FlutterViewController else { return }
|
||||
let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: rootVC.binaryMessenger)
|
||||
eventChannel.setStreamHandler(handler)
|
||||
}
|
||||
|
||||
private func registerNotifications() {
|
||||
// SyncedSharedPreferences
|
||||
notificationCenter.addObserver(self, selector: #selector(setBool(_:)), name: .setBool, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(getBool(_:)), name: .getBool, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(setStringList(_:)), name: .setStringList, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(getStringList(_:)), name: .getStringList, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(clearAll(_:)), name: .clearAll, object: nil)
|
||||
}
|
||||
|
||||
@objc private func setBool(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
guard
|
||||
let params = notification.userInfo?["params"] as? [String: Any],
|
||||
let key = params[HackiCore.keyKey] as? String,
|
||||
let val = params[HackiCore.valKey] as? Bool else {
|
||||
resultCompletionBlock(false)
|
||||
return
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let allVals = keyStore.dictionaryRepresentation;
|
||||
let allKeys = allVals.keys
|
||||
|
||||
// Limit is 1024, reserve rest slots for fav and pins.
|
||||
if allKeys.count >= 1000 {
|
||||
for key in allKeys.filter({ $0.contains("hasRead") }) {
|
||||
keyStore.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
keyStore.set(val, forKey: key)
|
||||
|
||||
resultCompletionBlock(true)
|
||||
}
|
||||
|
||||
@objc private func getBool(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
guard
|
||||
let params = notification.userInfo?["params"] as? [String: Any],
|
||||
let key = params[HackiCore.keyKey] as? String else {
|
||||
resultCompletionBlock(false)
|
||||
return
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let val = keyStore.bool(forKey: key)
|
||||
|
||||
resultCompletionBlock(val)
|
||||
}
|
||||
|
||||
@objc private func setStringList(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
guard
|
||||
let params = notification.userInfo?["params"] as? [String: Any],
|
||||
let key = params[HackiCore.keyKey] as? String,
|
||||
let val = params[HackiCore.valKey] as? [String] else {
|
||||
resultCompletionBlock(false)
|
||||
return
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
keyStore.set(val, forKey: key)
|
||||
|
||||
resultCompletionBlock(true)
|
||||
}
|
||||
|
||||
@objc private func getStringList(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
guard
|
||||
let params = notification.userInfo?["params"] as? [String: Any],
|
||||
let key = params[HackiCore.keyKey] as? String else {
|
||||
resultCompletionBlock(false)
|
||||
return
|
||||
}
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let list = keyStore.array(forKey: key) as [Any]? ?? [Any]()
|
||||
|
||||
resultCompletionBlock(list)
|
||||
}
|
||||
|
||||
@objc private func clearAll(_ notification: Notification) {
|
||||
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
|
||||
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
let allVals = keyStore.dictionaryRepresentation;
|
||||
let allKeys = allVals.keys
|
||||
|
||||
for key in allKeys.filter({ $0.contains("hasRead") }) {
|
||||
keyStore.removeObject(forKey: key)
|
||||
}
|
||||
|
||||
resultCompletionBlock(true)
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@ -38,7 +38,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
@ -72,5 +72,9 @@
|
||||
<array>
|
||||
<string>applinks:example.com</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -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
@ -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
@ -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
@ -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
|
@ -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,
|
||||
),
|
||||
|
@ -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;
|
||||
|
@ -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());
|
||||
}
|
||||
|
||||
|
@ -46,6 +46,13 @@ class StoriesDownload extends StoriesEvent {
|
||||
List<Object?> get props => <Object?>[includingWebPage];
|
||||
}
|
||||
|
||||
class StoriesCancelDownload extends StoriesEvent {
|
||||
StoriesCancelDownload();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class StoryDownloaded extends StoriesEvent {
|
||||
StoryDownloaded({required this.skipped});
|
||||
|
||||
|
@ -11,6 +11,7 @@ enum StoriesDownloadStatus {
|
||||
downloading,
|
||||
finished,
|
||||
failure,
|
||||
canceled,
|
||||
}
|
||||
|
||||
class StoriesState extends Equatable {
|
||||
@ -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,
|
||||
|
@ -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]+';
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
|
||||
/// Custom router.
|
||||
@ -39,8 +40,8 @@ class CustomRouter {
|
||||
appBar: AppBar(
|
||||
title: const Text('Error'),
|
||||
),
|
||||
body: const Center(
|
||||
child: Text('Something went wrong!'),
|
||||
body: Center(
|
||||
child: Text(Constants.errorMessage),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
41
lib/config/file_output.dart
Normal file
@ -0,0 +1,41 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// Writes the log output to a file.
|
||||
/// Temporary solution to not being able to access
|
||||
// ignore: comment_references
|
||||
/// the original [FileOutput] from [Logger]
|
||||
class CustomFileOutput extends LogOutput {
|
||||
CustomFileOutput({
|
||||
required this.file,
|
||||
this.overrideExisting = false,
|
||||
this.encoding = utf8,
|
||||
});
|
||||
|
||||
final File file;
|
||||
final bool overrideExisting;
|
||||
final Encoding encoding;
|
||||
IOSink? _sink;
|
||||
|
||||
@override
|
||||
void init() {
|
||||
_sink = file.openWrite(
|
||||
mode: overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend,
|
||||
encoding: encoding,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void output(OutputEvent event) {
|
||||
_sink?.writeAll(event.lines, '\n');
|
||||
_sink?.writeln();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> destroy() async {
|
||||
await _sink?.flush();
|
||||
await _sink?.close();
|
||||
}
|
||||
}
|
@ -1,8 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hacki/config/custom_log_filter.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// Global [GetIt.instance].
|
||||
@ -10,8 +13,16 @@ final GetIt locator = GetIt.instance;
|
||||
|
||||
/// Set up [GetIt] locator.
|
||||
Future<void> setUpLocator() async {
|
||||
final File logOutputFile = await LogUtil.initLogFile();
|
||||
|
||||
locator
|
||||
..registerSingleton<Logger>(Logger(filter: CustomLogFilter()))
|
||||
..registerSingleton<Logger>(
|
||||
Logger(
|
||||
filter: CustomLogFilter(),
|
||||
printer: LogUtil.logPrinter,
|
||||
output: LogUtil.logOutput(logOutputFile),
|
||||
),
|
||||
)
|
||||
..registerSingleton<StoriesRepository>(StoriesRepository())
|
||||
..registerSingleton<PreferenceRepository>(PreferenceRepository())
|
||||
..registerSingleton<SearchRepository>(SearchRepository())
|
||||
|
@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
|
||||
part 'collapse_state.dart';
|
||||
@ -10,13 +11,16 @@ part 'collapse_state.dart';
|
||||
class CollapseCubit extends Cubit<CollapseState> {
|
||||
CollapseCubit({
|
||||
required int commentId,
|
||||
required CommentsCubit? commentsCubit,
|
||||
CollapseCache? collapseCache,
|
||||
}) : _commentId = commentId,
|
||||
_collapseCache = collapseCache ?? locator.get<CollapseCache>(),
|
||||
_commentsCubit = commentsCubit,
|
||||
super(const CollapseState.init());
|
||||
|
||||
final int _commentId;
|
||||
final CollapseCache _collapseCache;
|
||||
final CommentsCubit? _commentsCubit;
|
||||
late final StreamSubscription<Map<int, Set<int>>> _streamSubscription;
|
||||
|
||||
void init() {
|
||||
@ -43,12 +47,21 @@ class CollapseCubit extends Cubit<CollapseState> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final int count = _collapseCache.collapse(_commentId);
|
||||
if (_commentsCubit == null) return;
|
||||
|
||||
final Set<int> collapsedCommentIds = _collapseCache.collapse(_commentId);
|
||||
final int lastCommentId = _commentsCubit!.state.comments.last.id;
|
||||
final bool shouldLoadMore = _commentId == lastCommentId ||
|
||||
collapsedCommentIds.contains(lastCommentId);
|
||||
|
||||
if (shouldLoadMore) {
|
||||
_commentsCubit!.loadMore();
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
collapsed: true,
|
||||
collapsedCount: state.collapsed ? 0 : count,
|
||||
collapsedCount: state.collapsed ? 0 : collapsedCommentIds.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -42,9 +42,9 @@ class FavState extends Equatable {
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
favIds,
|
||||
favItems,
|
||||
status,
|
||||
currentPage,
|
||||
favIds,
|
||||
favItems,
|
||||
];
|
||||
}
|
||||
|
@ -42,9 +42,9 @@ class HistoryState extends Equatable {
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
submittedIds,
|
||||
submittedItems,
|
||||
status,
|
||||
currentPage,
|
||||
submittedIds,
|
||||
submittedItems,
|
||||
];
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -77,11 +77,11 @@ class NotificationState extends Equatable {
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
comments,
|
||||
unreadCommentsIds,
|
||||
allCommentsIds,
|
||||
currentPage,
|
||||
offset,
|
||||
status,
|
||||
comments,
|
||||
unreadCommentsIds,
|
||||
allCommentsIds,
|
||||
];
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
46
lib/cubits/tab/tab_cubit.dart
Normal file
@ -0,0 +1,46 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
part 'tab_state.dart';
|
||||
|
||||
class TabCubit extends Cubit<TabState> {
|
||||
TabCubit({
|
||||
required PreferenceCubit preferenceCubit,
|
||||
Logger? logger,
|
||||
}) : _preferenceCubit = preferenceCubit,
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(TabState.init());
|
||||
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final Logger _logger;
|
||||
|
||||
void init() {
|
||||
final List<StoryType> tabs = _preferenceCubit.state.tabs;
|
||||
|
||||
_logger.i('updating tabs to $tabs');
|
||||
|
||||
emit(state.copyWith(tabs: tabs));
|
||||
}
|
||||
|
||||
void update(int startIndex, int endIndex) {
|
||||
_logger.d('updating ${state.tabs} by moving $startIndex to $endIndex');
|
||||
final StoryType tab = state.tabs.elementAt(startIndex);
|
||||
final List<StoryType> updatedTabs = List<StoryType>.from(state.tabs)
|
||||
..insert(endIndex, tab)
|
||||
..removeAt(startIndex < endIndex ? startIndex : startIndex + 1);
|
||||
_logger.d(updatedTabs);
|
||||
emit(state.copyWith(tabs: updatedTabs));
|
||||
|
||||
// Check to make sure there's no duplicate.
|
||||
if (updatedTabs.toSet().length == StoryType.values.length) {
|
||||
_preferenceCubit.update<int>(
|
||||
TabOrderPreference(),
|
||||
to: StoryType.convertToSettingsValue(updatedTabs),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
18
lib/cubits/tab/tab_state.dart
Normal file
@ -0,0 +1,18 @@
|
||||
part of 'tab_cubit.dart';
|
||||
|
||||
class TabState extends Equatable {
|
||||
const TabState({required this.tabs});
|
||||
|
||||
TabState.init() : tabs = <StoryType>[];
|
||||
|
||||
final List<StoryType> tabs;
|
||||
|
||||
TabState copyWith({
|
||||
List<StoryType>? tabs,
|
||||
}) {
|
||||
return TabState(tabs: tabs ?? this.tabs);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[tabs];
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -3,6 +3,7 @@ export 'date_time_extension.dart';
|
||||
export 'int_extension.dart';
|
||||
export 'list_extension.dart';
|
||||
export 'object_extension.dart';
|
||||
export 'set_extension.dart';
|
||||
export 'state_extension.dart';
|
||||
export 'string_extension.dart';
|
||||
export 'widget_extension.dart';
|
||||
|
@ -1,7 +1,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()}');
|
||||
}
|
||||
}
|
||||
|
13
lib/extensions/set_extension.dart
Normal file
@ -0,0 +1,13 @@
|
||||
extension SetExtension<E> on Set<E> {
|
||||
void removeWhereType<T extends E>() {
|
||||
return removeWhere((E e) => e is T);
|
||||
}
|
||||
|
||||
bool hasType<T extends E>() {
|
||||
return whereType<T>().isNotEmpty;
|
||||
}
|
||||
|
||||
T singleWhereType<T extends E>() {
|
||||
return whereType<T>().single;
|
||||
}
|
||||
}
|
@ -1,9 +1,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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
102
lib/main.dart
@ -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
|
||||
|
@ -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?>[
|
||||
|
9
lib/models/comments_order.dart
Normal file
@ -0,0 +1,9 @@
|
||||
enum CommentsOrder {
|
||||
natural('Natural'),
|
||||
newestFirst('Newest first'),
|
||||
oldestFirst('Oldest first');
|
||||
|
||||
const CommentsOrder(this.description);
|
||||
|
||||
final String description;
|
||||
}
|
9
lib/models/displayable.dart
Normal file
@ -0,0 +1,9 @@
|
||||
mixin SettingsDisplayable {
|
||||
String get title;
|
||||
|
||||
String get subtitle => '';
|
||||
|
||||
/// Whether or not this should be displayed
|
||||
/// in settings.
|
||||
bool get isDisplayable => true;
|
||||
}
|
8
lib/models/fetch_mode.dart
Normal file
@ -0,0 +1,8 @@
|
||||
enum FetchMode {
|
||||
lazy('Lazy'),
|
||||
eager('Eager');
|
||||
|
||||
const FetchMode(this.description);
|
||||
|
||||
final String description;
|
||||
}
|
13
lib/models/font_size.dart
Normal file
@ -0,0 +1,13 @@
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
enum FontSize {
|
||||
small('Small', TextDimens.pt15),
|
||||
regular('Regular', TextDimens.pt16),
|
||||
large('Large', TextDimens.pt17),
|
||||
xlarge('XLarge', TextDimens.pt18);
|
||||
|
||||
const FontSize(this.description, this.fontSize);
|
||||
|
||||
final String description;
|
||||
final double fontSize;
|
||||
}
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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
@ -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';
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
part of 'search_filters.dart';
|
||||
part of 'search_params.dart';
|
||||
|
||||
abstract class SearchFilter {
|
||||
String get query;
|
||||
|
@ -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,
|
@ -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,
|
||||
|
34
lib/models/story_type.dart
Normal file
@ -0,0 +1,34 @@
|
||||
enum StoryType {
|
||||
top('topstories'),
|
||||
best('beststories'),
|
||||
latest('newstories'),
|
||||
ask('askstories'),
|
||||
show('showstories');
|
||||
|
||||
const StoryType(this.path);
|
||||
|
||||
final String path;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case StoryType.top:
|
||||
return 'TOP';
|
||||
case StoryType.best:
|
||||
return 'BEST';
|
||||
case StoryType.latest:
|
||||
return 'NEW';
|
||||
case StoryType.ask:
|
||||
return 'ASK';
|
||||
case StoryType.show:
|
||||
return 'SHOW';
|
||||
}
|
||||
}
|
||||
|
||||
static int convertToSettingsValue(List<StoryType> tabs) {
|
||||
return int.parse(
|
||||
tabs
|
||||
.map((StoryType e) => e.index.toString())
|
||||
.reduce((String value, String element) => '$value$element'),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,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,
|
||||
];
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
337
lib/screens/home/home_screen.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
31
lib/screens/home/widgets/mobile_home_screen.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
72
lib/screens/home/widgets/pinned_stories.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|