Compare commits
41 Commits
Author | SHA1 | Date | |
---|---|---|---|
7dc3618afe | |||
eef4691814 | |||
9f71701845 | |||
d27203b041 | |||
4f280ec4c9 | |||
72cb2737ca | |||
215203bd16 | |||
3e320faece | |||
1049568246 | |||
71aa42118d | |||
4f21d3e6bd | |||
96d0fe9e5e | |||
69eee3e278 | |||
36bcd996c0 | |||
5fc39d8b8b | |||
5dce7787e1 | |||
8888dde792 | |||
6c8fc4cf87 | |||
ae9cc109db | |||
c8976ed17b | |||
ff7e115418 | |||
0310507c96 | |||
58c646e232 | |||
08328e2ca1 | |||
86b7228ffd | |||
e103c88ca6 | |||
94323a04e0 | |||
4776c375a1 | |||
1f4e6cf41c | |||
be6ed35888 | |||
b2ea50cea6 | |||
109b9287cf | |||
939d55ef0d | |||
3ee60e1a44 | |||
6fe567fa02 | |||
bc2d4f32c9 | |||
91290e9743 | |||
934f184b6f | |||
dbd48eae99 | |||
279007191b | |||
b3fdc20fc5 |
32
README.md
@ -1,7 +1,7 @@
|
||||
|
||||
# <img width="64" src="https://user-images.githubusercontent.com/7277662/167775086-0b234f28-dee4-44f6-aae4-14a28ed4bbb6.png"> Hacki for Hacker News
|
||||
|
||||
A [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
|
||||
A [Hacker News](https://news.ycombinator.com/) client built with Flutter.
|
||||
|
||||
[](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
|
||||
[](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
|
||||
@ -35,22 +35,20 @@ Features:
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img width="200" alt="01" src="assets/screenshots/01.png">
|
||||
<img width="200" alt="02" src="assets/screenshots/02.png">
|
||||
<img width="200" alt="03" src="assets/screenshots/03.png">
|
||||
<img width="200" alt="04" src="assets/screenshots/04.png">
|
||||
<img width="200" alt="05" src="assets/screenshots/05.png">
|
||||
<img width="200" alt="06" src="assets/screenshots/06.png">
|
||||
<img width="200" alt="07" src="assets/screenshots/07.png">
|
||||
<img width="200" alt="08" src="assets/screenshots/08.png">
|
||||
<img width="200" alt="09" src="assets/screenshots/09.png">
|
||||
<img width="200" alt="10" src="assets/screenshots/10.png">
|
||||
<img width="200" alt="11" src="assets/screenshots/11.png">
|
||||
<img width="200" alt="12" src="assets/screenshots/12.png">
|
||||
<img width="400" alt="01" src="assets/screenshots/light-1.png">
|
||||
<img width="400" alt="06" src="assets/screenshots/dark-1.png">
|
||||
<img width="400" alt="02" src="assets/screenshots/light-2.png">
|
||||
<img width="400" alt="07" src="assets/screenshots/dark-2.png">
|
||||
<img width="400" alt="03" src="assets/screenshots/light-3.png">
|
||||
<img width="400" alt="08" src="assets/screenshots/dark-3.png">
|
||||
<img width="400" alt="04" src="assets/screenshots/light-4.png">
|
||||
<img width="400" alt="09" src="assets/screenshots/dark-4.png">
|
||||
<img width="400" alt="05" src="assets/screenshots/light-5.png">
|
||||
<img width="400" alt="10" src="assets/screenshots/dark-5.png">
|
||||
|
||||
<img width="400" alt="ipad-01" src="assets/screenshots/ipad-01.png">
|
||||
<img width="400" alt="ipad-02" src="assets/screenshots/ipad-02.png">
|
||||
<img width="400" alt="ipad-03" src="assets/screenshots/ipad-03.png">
|
||||
<img width="400" alt="ipad-04" src="assets/screenshots/ipad-04.png">
|
||||
<img width="400" alt="ipad-01" src="assets/screenshots/tablet-light-1.png">
|
||||
<img width="400" alt="ipad-02" src="assets/screenshots/tablet-dark-1.png">
|
||||
<img width="400" alt="ipad-03" src="assets/screenshots/tablet-light-2.png">
|
||||
<img width="400" alt="ipad-04" src="assets/screenshots/tablet-dark-2.png">
|
||||
</p>
|
||||
|
||||
|
@ -23,7 +23,8 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
|
BIN
assets/fonts/exo_2/Exo2-Bold.ttf
Normal file
BIN
assets/fonts/exo_2/Exo2-Regular.ttf
Normal file
BIN
assets/hacki-github.png
Normal file
After Width: | Height: | Size: 419 KiB |
BIN
assets/hacki.xcf
Before Width: | Height: | Size: 548 KiB After Width: | Height: | Size: 333 KiB |
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 341 KiB |
Before Width: | Height: | Size: 592 KiB After Width: | Height: | Size: 359 KiB |
BIN
assets/screenshots/dark-1.png
Normal file
After Width: | Height: | Size: 1003 KiB |
BIN
assets/screenshots/dark-2.png
Normal file
After Width: | Height: | Size: 912 KiB |
BIN
assets/screenshots/dark-3.png
Normal file
After Width: | Height: | Size: 252 KiB |
BIN
assets/screenshots/dark-4.png
Normal file
After Width: | Height: | Size: 734 KiB |
BIN
assets/screenshots/dark-5.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/screenshots/light-1.png
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/screenshots/light-2.png
Normal file
After Width: | Height: | Size: 893 KiB |
BIN
assets/screenshots/light-3.png
Normal file
After Width: | Height: | Size: 460 KiB |
BIN
assets/screenshots/light-4.png
Normal file
After Width: | Height: | Size: 712 KiB |
BIN
assets/screenshots/light-5.png
Normal file
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/screenshots/tablet-dark-1.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/screenshots/tablet-dark-2.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/screenshots/tablet-light-1.png
Normal file
After Width: | Height: | Size: 1.9 MiB |
BIN
assets/screenshots/tablet-light-2.png
Normal file
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/tablet-hacki.xcf
Normal file
@ -76,6 +76,15 @@ final class SharedPrefsCore {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fileprivate func remove(key: String?) -> Bool{
|
||||
if let key = key {
|
||||
let keyStore = NSUbiquitousKeyValueStore()
|
||||
keyStore.removeObject(forKey: key)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
|
||||
@ -87,6 +96,14 @@ public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "remove":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let key = params[keyKey] as? String
|
||||
|
||||
let res = SharedPrefsCore.shared.remove(key: key)
|
||||
result(res)
|
||||
}
|
||||
|
||||
case "setBool":
|
||||
if let params = call.arguments as? [String: Any] {
|
||||
let val = params[valKey] as? Bool
|
||||
|
@ -15,6 +15,14 @@ class SyncedSharedPreferences {
|
||||
const MethodChannel(channel),
|
||||
);
|
||||
|
||||
Future<bool?> remove({
|
||||
required String key,
|
||||
}) async {
|
||||
return _channel.invokeMethod('remove', <String, dynamic>{
|
||||
'key': key,
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool?> setBool({
|
||||
required String key,
|
||||
required bool val,
|
||||
|
4
fastlane/metadata/android/en-US/changelogs/127.txt
Normal file
@ -0,0 +1,4 @@
|
||||
- Ability to use Material 3.
|
||||
- Ability to search in thread.
|
||||
- Ability to customize text scale factor.
|
||||
- Ability to customize app's accent color.
|
5
fastlane/metadata/android/en-US/changelogs/128.txt
Normal file
@ -0,0 +1,5 @@
|
||||
- Ability to use pagination on home screen.
|
||||
- Ability to use Material 3 (experimental).
|
||||
- Ability to search in thread.
|
||||
- Ability to customize text scale factor.
|
||||
- Ability to customize app's accent color.
|
5
fastlane/metadata/android/en-US/changelogs/129.txt
Normal file
@ -0,0 +1,5 @@
|
||||
- Ability to use manual pagination on home screen.
|
||||
- Ability to use Material 3 (experimental).
|
||||
- Ability to search in thread.
|
||||
- Ability to customize text scale factor.
|
||||
- Ability to customize app's accent color.
|
4
fastlane/metadata/android/en-US/changelogs/131.txt
Normal file
@ -0,0 +1,4 @@
|
||||
- New comment indicator.
|
||||
- Ability to mark stories as read from home page.
|
||||
- Text rendering improvements.
|
||||
- Performance improvements.
|
4
fastlane/metadata/android/en-US/changelogs/132.txt
Normal file
@ -0,0 +1,4 @@
|
||||
- New comment indicator.
|
||||
- Ability to mark stories as read from home page.
|
||||
- Text rendering improvements.
|
||||
- Performance improvements.
|
4
fastlane/metadata/android/en-US/changelogs/134.txt
Normal file
@ -0,0 +1,4 @@
|
||||
- RobotoSlab as default font.
|
||||
- Material 3 design.
|
||||
- Ability to sync favorites from your Hacker News account.
|
||||
- Support for predictive back gesture.
|
Before Width: | Height: | Size: 522 KiB |
Before Width: | Height: | Size: 835 KiB |
Before Width: | Height: | Size: 282 KiB |
Before Width: | Height: | Size: 298 KiB |
Before Width: | Height: | Size: 820 KiB |
Before Width: | Height: | Size: 868 KiB |
Before Width: | Height: | Size: 121 KiB |
Before Width: | Height: | Size: 375 KiB |
Before Width: | Height: | Size: 282 KiB |
Before Width: | Height: | Size: 414 KiB |
Before Width: | Height: | Size: 530 KiB |
Before Width: | Height: | Size: 406 KiB |
After Width: | Height: | Size: 1003 KiB |
After Width: | Height: | Size: 912 KiB |
After Width: | Height: | Size: 252 KiB |
After Width: | Height: | Size: 734 KiB |
After Width: | Height: | Size: 1.1 MiB |
After Width: | Height: | Size: 1.3 MiB |
After Width: | Height: | Size: 893 KiB |
After Width: | Height: | Size: 460 KiB |
After Width: | Height: | Size: 712 KiB |
After Width: | Height: | Size: 1.0 MiB |
@ -159,7 +159,7 @@ SPEC CHECKSUMS:
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
url_launcher_ios: 68d46cc9766d0c41dbdc884310529557e3cd7a86
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
|
||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||
|
@ -11,13 +11,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
AuthBloc({
|
||||
AuthRepository? authRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
}) : _authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
super(const AuthState.init()) {
|
||||
@ -31,7 +31,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
final AuthRepository _authRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
|
||||
Future<void> onInitialize(
|
||||
@ -41,7 +41,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
await _authRepository.loggedIn.then((bool loggedIn) async {
|
||||
if (loggedIn) {
|
||||
final String? username = await _authRepository.username;
|
||||
User? user = await _storiesRepository.fetchUser(id: username!);
|
||||
User? user = await _hackerNewsRepository.fetchUser(id: username!);
|
||||
|
||||
/// According to Hacker News' API documentation,
|
||||
/// if user has no public activity (posting a comment or story),
|
||||
@ -89,7 +89,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
if (successful) {
|
||||
final User? user = await _storiesRepository.fetchUser(id: event.username);
|
||||
final User? user =
|
||||
await _hackerNewsRepository.fetchUser(id: event.username);
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user ?? User.emptyWithId(event.username),
|
||||
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:bloc_concurrency/bloc_concurrency.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
@ -19,24 +20,32 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
required PreferenceCubit preferenceCubit,
|
||||
required FilterCubit filterCubit,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
Logger? logger,
|
||||
}) : _preferenceCubit = preferenceCubit,
|
||||
_filterCubit = filterCubit,
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(const StoriesState.init()) {
|
||||
on<LoadStories>(
|
||||
onLoadStories,
|
||||
transformer: sequential(),
|
||||
);
|
||||
on<StoriesInitialize>(onInitialize);
|
||||
on<StoriesRefresh>(onRefresh);
|
||||
on<StoriesLoadMore>(onLoadMore);
|
||||
on<StoryLoaded>(onStoryLoaded);
|
||||
on<StoryLoaded>(
|
||||
onStoryLoaded,
|
||||
transformer: sequential(),
|
||||
);
|
||||
on<StoryRead>(onStoryRead);
|
||||
on<StoryUnread>(onStoryUnread);
|
||||
on<StoriesLoaded>(onStoriesLoaded);
|
||||
on<StoriesDownload>(onDownload);
|
||||
on<StoriesCancelDownload>(onCancelDownload);
|
||||
@ -49,7 +58,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final FilterCubit _filterCubit;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final Logger _logger;
|
||||
DeviceScreenType? deviceScreenType;
|
||||
@ -87,14 +96,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
),
|
||||
);
|
||||
for (final StoryType type in StoryType.values) {
|
||||
await loadStories(type: type, emit: emit);
|
||||
add(LoadStories(type: type));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadStories({
|
||||
required StoryType type,
|
||||
required Emitter<StoriesState> emit,
|
||||
}) async {
|
||||
Future<void> onLoadStories(
|
||||
LoadStories event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
final StoryType type = event.type;
|
||||
if (state.isOfflineReading) {
|
||||
final List<int> ids =
|
||||
await _offlineRepository.getCachedStoryIds(type: type);
|
||||
@ -113,19 +123,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
} else {
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
|
||||
final List<int> ids =
|
||||
await _hackerNewsRepository.fetchStoryIds(type: type);
|
||||
emit(
|
||||
state
|
||||
.copyWithStoryIdsUpdated(type: type, to: ids)
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0),
|
||||
);
|
||||
_storiesRepository
|
||||
await _hackerNewsRepository
|
||||
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
}).asFuture<void>();
|
||||
add(StoriesLoaded(type: type));
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,7 +161,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWithRefreshed(type: event.type));
|
||||
await loadStories(type: event.type, emit: emit);
|
||||
add(LoadStories(type: event.type));
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,7 +206,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
add(StoriesLoaded(type: event.type));
|
||||
});
|
||||
} else {
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
@ -273,7 +283,8 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
..remove(StoryType.latest);
|
||||
|
||||
for (final StoryType type in prioritizedTypes) {
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
|
||||
final List<int> ids =
|
||||
await _hackerNewsRepository.fetchStoryIds(type: type);
|
||||
await _offlineRepository.cacheStoryIds(type: type, ids: ids);
|
||||
prioritizedIds.addAll(ids);
|
||||
}
|
||||
@ -293,7 +304,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
);
|
||||
|
||||
final Set<int> latestIds = <int>{};
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(
|
||||
final List<int> ids = await _hackerNewsRepository.fetchStoryIds(
|
||||
type: StoryType.latest,
|
||||
);
|
||||
await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids);
|
||||
@ -347,7 +358,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
|
||||
_logger.d('fetching story $id');
|
||||
final Story? story = await _storiesRepository.fetchStory(id: id);
|
||||
final Story? story = await _hackerNewsRepository.fetchStory(id: id);
|
||||
|
||||
if (story == null) {
|
||||
if (isPrioritized) {
|
||||
@ -377,7 +388,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
/// In other words, we are prioritizing the story itself instead of
|
||||
/// the comments in the story.
|
||||
late final StreamSubscription<Comment>? downloadStream;
|
||||
downloadStream = _storiesRepository
|
||||
downloadStream = _hackerNewsRepository
|
||||
.fetchAllChildrenComments(ids: story.kids)
|
||||
.whereType<Comment>()
|
||||
.listen(
|
||||
@ -460,7 +471,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoryRead event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
unawaited(_preferenceRepository.updateHasRead(event.story.id));
|
||||
unawaited(_preferenceRepository.addHasRead(event.story.id));
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -469,6 +480,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onStoryUnread(
|
||||
StoryUnread event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
unawaited(_preferenceRepository.removeHasRead(event.story.id));
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
readStoriesIds: <int>{...state.readStoriesIds}..remove(event.story.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onClearAllReadStories(
|
||||
ClearAllReadStories event,
|
||||
Emitter<StoriesState> emit,
|
||||
|
@ -5,6 +5,15 @@ abstract class StoriesEvent extends Equatable {
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class LoadStories extends StoriesEvent {
|
||||
LoadStories({required this.type});
|
||||
|
||||
final StoryType type;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[type];
|
||||
}
|
||||
|
||||
class StoriesInitialize extends StoriesEvent {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
@ -95,6 +104,15 @@ class StoryRead extends StoriesEvent {
|
||||
List<Object?> get props => <Object?>[story];
|
||||
}
|
||||
|
||||
class StoryUnread extends StoriesEvent {
|
||||
StoryUnread({required this.story});
|
||||
|
||||
final Story story;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[story];
|
||||
}
|
||||
|
||||
class ClearAllReadStories extends StoriesEvent {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
|
@ -20,6 +20,8 @@ abstract class Constants {
|
||||
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
|
||||
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
|
||||
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
|
||||
static const String hackerNewsItemLinkPrefix =
|
||||
'https://news.ycombinator.com/item?id=';
|
||||
static const String supportEmail = 'georgefung98@gmail.com';
|
||||
|
||||
static const String _imagePath = 'assets/images';
|
||||
@ -71,7 +73,7 @@ abstract class RegExpConstants {
|
||||
static const String number = '[0-9]+';
|
||||
}
|
||||
|
||||
abstract class Durations {
|
||||
abstract class AppDurations {
|
||||
static const Duration ms100 = Duration(milliseconds: 100);
|
||||
static const Duration ms200 = Duration(milliseconds: 200);
|
||||
static const Duration ms300 = Duration(milliseconds: 300);
|
||||
|
@ -23,12 +23,13 @@ Future<void> setUpLocator() async {
|
||||
output: LogUtil.logOutput(logOutputFile),
|
||||
),
|
||||
)
|
||||
..registerSingleton<StoriesRepository>(StoriesRepository())
|
||||
..registerSingleton<SembastRepository>(SembastRepository())
|
||||
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
|
||||
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
|
||||
..registerSingleton<PreferenceRepository>(PreferenceRepository())
|
||||
..registerSingleton<SearchRepository>(SearchRepository())
|
||||
..registerSingleton<AuthRepository>(AuthRepository())
|
||||
..registerSingleton<PostRepository>(PostRepository())
|
||||
..registerSingleton<SembastRepository>(SembastRepository())
|
||||
..registerSingleton<OfflineRepository>(OfflineRepository())
|
||||
..registerSingleton<DraftCache>(DraftCache())
|
||||
..registerSingleton<CommentCache>(CommentCache())
|
||||
|
@ -9,6 +9,7 @@ import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/custom_router.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
@ -31,18 +32,18 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
required CommentsOrder defaultCommentsOrder,
|
||||
CommentCache? commentCache,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
Logger? logger,
|
||||
}) : _filterCubit = filterCubit,
|
||||
_collapseCache = collapseCache,
|
||||
_commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(
|
||||
CommentsState.init(
|
||||
@ -57,10 +58,14 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final CollapseCache _collapseCache;
|
||||
final CommentCache _commentCache;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final Logger _logger;
|
||||
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
/// The [StreamSubscription] for stream (both lazy or eager)
|
||||
/// fetching comments posted directly to the story.
|
||||
StreamSubscription<Comment>? _streamSubscription;
|
||||
@ -91,7 +96,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
),
|
||||
);
|
||||
|
||||
_streamSubscription = _storiesRepository
|
||||
_streamSubscription = _hackerNewsRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: targetAncestors!.last.kids,
|
||||
level: targetAncestors.last.level + 1,
|
||||
@ -108,6 +113,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
state.copyWith(
|
||||
status: CommentsStatus.inProgress,
|
||||
comments: <Comment>[],
|
||||
matchedComments: <int>[],
|
||||
inThreadSearchQuery: '',
|
||||
currentPage: 0,
|
||||
),
|
||||
);
|
||||
@ -115,7 +122,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final Item item = state.item;
|
||||
final Item updatedItem = state.isOfflineReading
|
||||
? item
|
||||
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
|
||||
: await _hackerNewsRepository
|
||||
.fetchItem(id: item.id)
|
||||
.then(_toBuildable)
|
||||
.onError((_, __) => item) ??
|
||||
item;
|
||||
final List<int> kids = _sortKids(updatedItem.kids);
|
||||
|
||||
@ -128,12 +138,13 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
} else {
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
commentStream = _hackerNewsRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
case FetchMode.eager:
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
commentStream =
|
||||
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
@ -180,16 +191,16 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
final Item item = state.item;
|
||||
final Item updatedItem =
|
||||
await _storiesRepository.fetchItem(id: item.id) ?? item;
|
||||
await _hackerNewsRepository.fetchItem(id: item.id) ?? item;
|
||||
final List<int> kids = _sortKids(updatedItem.kids);
|
||||
|
||||
late final Stream<Comment> commentStream;
|
||||
if (state.fetchMode == FetchMode.lazy) {
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
commentStream = _hackerNewsRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
);
|
||||
} else {
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
commentStream = _hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
);
|
||||
}
|
||||
@ -213,6 +224,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
state.copyWith(
|
||||
onlyShowTargetComment: false,
|
||||
item: story,
|
||||
matchedComments: <int>[],
|
||||
),
|
||||
);
|
||||
init();
|
||||
@ -237,14 +249,17 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
/// Ignoring because the subscription will be cancelled in close()
|
||||
// ignore: cancel_subscriptions
|
||||
final StreamSubscription<Comment> streamSubscription =
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchCommentsStream(ids: comment.kids)
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen((Comment cmt) {
|
||||
_collapseCache.addKid(cmt.id, to: cmt.parent);
|
||||
_commentCache.cacheComment(cmt);
|
||||
_sembastRepository.cacheComment(cmt);
|
||||
|
||||
final Map<int, Comment> updatedIdToCommentMap =
|
||||
Map<int, Comment>.from(state.idToCommentMap);
|
||||
updatedIdToCommentMap[comment.id] = comment;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -252,6 +267,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
state.comments.indexOf(comment) + offset + 1,
|
||||
cmt.copyWith(level: level),
|
||||
),
|
||||
idToCommentMap: updatedIdToCommentMap,
|
||||
),
|
||||
);
|
||||
offset++;
|
||||
@ -281,7 +297,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
HapticFeedbackUtil.light();
|
||||
emit(state.copyWith(fetchParentStatus: CommentsStatus.inProgress));
|
||||
final Item? parent =
|
||||
await _storiesRepository.fetchItem(id: state.item.parent);
|
||||
await _hackerNewsRepository.fetchItem(id: state.item.parent);
|
||||
|
||||
if (parent == null) {
|
||||
return;
|
||||
@ -302,7 +318,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
Future<void> loadRootThread() async {
|
||||
HapticFeedbackUtil.light();
|
||||
emit(state.copyWith(fetchRootStatus: CommentsStatus.inProgress));
|
||||
final Story? parent = await _storiesRepository
|
||||
final Story? parent = await _hackerNewsRepository
|
||||
.fetchParentStory(id: state.item.id)
|
||||
.then(_toBuildableStory);
|
||||
|
||||
@ -349,11 +365,20 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
init(useCommentCache: true);
|
||||
}
|
||||
|
||||
void scrollTo({
|
||||
required int index,
|
||||
double alignment = 0.0,
|
||||
}) {
|
||||
debugPrint('Scrolling to: $index, alignment: $alignment');
|
||||
itemScrollController.scrollTo(
|
||||
index: index,
|
||||
alignment: alignment,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
}
|
||||
|
||||
/// Scroll to next root level comment.
|
||||
void scrollToNextRoot(
|
||||
ItemScrollController itemScrollController,
|
||||
ItemPositionsListener itemPositionsListener,
|
||||
) {
|
||||
void scrollToNextRoot({VoidCallback? onError}) {
|
||||
final int totalComments = state.comments.length;
|
||||
final List<Comment> onScreenComments = itemPositionsListener
|
||||
.itemPositions.value
|
||||
@ -373,15 +398,25 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
itemScrollController.scrollTo(
|
||||
index: 1,
|
||||
alignment: 0.15,
|
||||
duration: Durations.ms400,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/// The index of first root level comment visible on screen.
|
||||
final int firstVisibleRootIndex = state.comments
|
||||
.indexOf(onScreenComments.firstWhere((Comment e) => e.isRoot));
|
||||
final int startIndex = min(firstVisibleRootIndex + 1, totalComments);
|
||||
final Comment? firstVisibleRootComment =
|
||||
onScreenComments.firstWhereOrNull((Comment e) => e.isRoot);
|
||||
late int startIndex;
|
||||
|
||||
if (firstVisibleRootComment != null) {
|
||||
/// The index of first root level comment visible on screen.
|
||||
final int firstVisibleRootCommentIndex =
|
||||
state.comments.indexOf(firstVisibleRootComment);
|
||||
startIndex = min(firstVisibleRootCommentIndex + 1, totalComments);
|
||||
} else {
|
||||
final int lastVisibleCommentIndex =
|
||||
state.comments.indexOf(onScreenComments.last);
|
||||
startIndex = min(lastVisibleCommentIndex + 1, totalComments);
|
||||
}
|
||||
|
||||
for (int i = startIndex; i < totalComments; i++) {
|
||||
final Comment cmt = state.comments.elementAt(i);
|
||||
@ -390,18 +425,19 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
itemScrollController.scrollTo(
|
||||
index: i + 1,
|
||||
alignment: 0.15,
|
||||
duration: Durations.ms400,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status == CommentsStatus.allLoaded) {
|
||||
onError?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll to previous root level comment.
|
||||
void scrollToPreviousRoot(
|
||||
ItemScrollController itemScrollController,
|
||||
ItemPositionsListener itemPositionsListener,
|
||||
) {
|
||||
void scrollToPreviousRoot() {
|
||||
final List<Comment> onScreenComments = itemPositionsListener
|
||||
.itemPositions.value
|
||||
// The header is also a part of the list view,
|
||||
@ -429,13 +465,57 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
itemScrollController.scrollTo(
|
||||
index: i + 1,
|
||||
alignment: 0.15,
|
||||
duration: Durations.ms400,
|
||||
duration: AppDurations.ms400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void search(String query, {String author = ''}) {
|
||||
resetSearch();
|
||||
|
||||
late final bool Function(Comment cmt) conditionSatisfied;
|
||||
final String lowercaseQuery = query.toLowerCase();
|
||||
if (query.isEmpty && author.isEmpty) {
|
||||
return;
|
||||
} else if (author.isEmpty) {
|
||||
conditionSatisfied =
|
||||
(Comment cmt) => cmt.text.toLowerCase().contains(lowercaseQuery);
|
||||
} else if (query.isEmpty) {
|
||||
conditionSatisfied = (Comment cmt) => cmt.by == author;
|
||||
} else {
|
||||
conditionSatisfied = (Comment cmt) =>
|
||||
cmt.text.toLowerCase().contains(lowercaseQuery) && cmt.by == author;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
inThreadSearchQuery: query,
|
||||
inThreadSearchAuthor: author,
|
||||
),
|
||||
);
|
||||
|
||||
for (final int i in 0.to(state.comments.length, inclusive: false)) {
|
||||
final Comment cmt = state.comments.elementAt(i);
|
||||
if (conditionSatisfied(cmt)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
matchedComments: <int>[...state.matchedComments, i],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void resetSearch() => emit(
|
||||
state.copyWith(
|
||||
matchedComments: <int>[],
|
||||
inThreadSearchQuery: '',
|
||||
inThreadSearchAuthor: '',
|
||||
),
|
||||
);
|
||||
|
||||
List<int> _sortKids(List<int> kids) {
|
||||
switch (state.order) {
|
||||
case CommentsOrder.natural:
|
||||
@ -461,8 +541,12 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
if (comment != null) {
|
||||
_collapseCache.addKid(comment.id, to: comment.parent);
|
||||
_commentCache.cacheComment(comment);
|
||||
_sembastRepository.cacheComment(comment);
|
||||
|
||||
if (state.isOfflineReading) {
|
||||
_sembastRepository.cacheComment(comment);
|
||||
}
|
||||
|
||||
// Hide comment that matches any of the filter keywords.
|
||||
final bool hidden = _filterCubit.state.keywords.any(
|
||||
(String keyword) => comment.text.toLowerCase().contains(keyword),
|
||||
);
|
||||
@ -471,7 +555,16 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
comment.copyWith(hidden: hidden),
|
||||
];
|
||||
|
||||
emit(state.copyWith(comments: updatedComments));
|
||||
final Map<int, Comment> updatedIdToCommentMap =
|
||||
Map<int, Comment>.from(state.idToCommentMap);
|
||||
updatedIdToCommentMap[comment.id] = comment;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
comments: updatedComments,
|
||||
idToCommentMap: updatedIdToCommentMap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,6 +12,8 @@ class CommentsState extends Equatable {
|
||||
const CommentsState({
|
||||
required this.item,
|
||||
required this.comments,
|
||||
required this.matchedComments,
|
||||
required this.idToCommentMap,
|
||||
required this.status,
|
||||
required this.fetchParentStatus,
|
||||
required this.fetchRootStatus,
|
||||
@ -20,6 +22,8 @@ class CommentsState extends Equatable {
|
||||
required this.onlyShowTargetComment,
|
||||
required this.isOfflineReading,
|
||||
required this.currentPage,
|
||||
required this.inThreadSearchQuery,
|
||||
required this.inThreadSearchAuthor,
|
||||
});
|
||||
|
||||
CommentsState.init({
|
||||
@ -28,14 +32,19 @@ class CommentsState extends Equatable {
|
||||
required this.fetchMode,
|
||||
required this.order,
|
||||
}) : comments = <Comment>[],
|
||||
matchedComments = <int>[],
|
||||
idToCommentMap = <int, Comment>{},
|
||||
status = CommentsStatus.idle,
|
||||
fetchParentStatus = CommentsStatus.idle,
|
||||
fetchRootStatus = CommentsStatus.idle,
|
||||
onlyShowTargetComment = false,
|
||||
currentPage = 0;
|
||||
currentPage = 0,
|
||||
inThreadSearchQuery = '',
|
||||
inThreadSearchAuthor = '';
|
||||
|
||||
final Item item;
|
||||
final List<Comment> comments;
|
||||
final Map<int, Comment> idToCommentMap;
|
||||
final CommentsStatus status;
|
||||
final CommentsStatus fetchParentStatus;
|
||||
final CommentsStatus fetchRootStatus;
|
||||
@ -44,10 +53,17 @@ class CommentsState extends Equatable {
|
||||
final bool onlyShowTargetComment;
|
||||
final bool isOfflineReading;
|
||||
final int currentPage;
|
||||
final String inThreadSearchQuery;
|
||||
final String inThreadSearchAuthor;
|
||||
|
||||
/// Indexes of comments that matches the query for in-thread search.
|
||||
final List<int> matchedComments;
|
||||
|
||||
CommentsState copyWith({
|
||||
Item? item,
|
||||
List<Comment>? comments,
|
||||
List<int>? matchedComments,
|
||||
Map<int, Comment>? idToCommentMap,
|
||||
CommentsStatus? status,
|
||||
CommentsStatus? fetchParentStatus,
|
||||
CommentsStatus? fetchRootStatus,
|
||||
@ -56,10 +72,13 @@ class CommentsState extends Equatable {
|
||||
bool? onlyShowTargetComment,
|
||||
bool? isOfflineReading,
|
||||
int? currentPage,
|
||||
String? inThreadSearchQuery,
|
||||
String? inThreadSearchAuthor,
|
||||
}) {
|
||||
return CommentsState(
|
||||
item: item ?? this.item,
|
||||
comments: comments ?? this.comments,
|
||||
matchedComments: matchedComments ?? this.matchedComments,
|
||||
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
|
||||
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
|
||||
status: status ?? this.status,
|
||||
@ -69,11 +88,41 @@ class CommentsState extends Equatable {
|
||||
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
||||
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
inThreadSearchQuery: inThreadSearchQuery ?? this.inThreadSearchQuery,
|
||||
inThreadSearchAuthor: inThreadSearchAuthor ?? this.inThreadSearchAuthor,
|
||||
idToCommentMap: idToCommentMap ?? this.idToCommentMap,
|
||||
);
|
||||
}
|
||||
|
||||
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
|
||||
|
||||
static final Map<int, bool> _isResponseCache = <int, bool>{};
|
||||
|
||||
bool isResponse(Comment comment) {
|
||||
if (_isResponseCache.containsKey(comment.id)) {
|
||||
return _isResponseCache[comment.id]!;
|
||||
}
|
||||
|
||||
if (comment.isRoot) {
|
||||
_isResponseCache[comment.id] = false;
|
||||
return false;
|
||||
}
|
||||
final Comment? precedingComment = idToCommentMap[comment.parent];
|
||||
if (precedingComment == null) {
|
||||
_isResponseCache[comment.id] = false;
|
||||
return false;
|
||||
} else if (item.id == precedingComment.parent && item.by == comment.by) {
|
||||
_isResponseCache[comment.id] = true;
|
||||
return true;
|
||||
} else if (idToCommentMap[precedingComment.parent]?.by == comment.by) {
|
||||
_isResponseCache[comment.id] = true;
|
||||
return true;
|
||||
} else {
|
||||
_isResponseCache[comment.id] = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
item,
|
||||
@ -86,5 +135,9 @@ class CommentsState extends Equatable {
|
||||
isOfflineReading,
|
||||
currentPage,
|
||||
comments,
|
||||
matchedComments,
|
||||
inThreadSearchQuery,
|
||||
inThreadSearchAuthor,
|
||||
idToCommentMap,
|
||||
];
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ part 'edit_state.dart';
|
||||
class EditCubit extends HydratedCubit<EditState> {
|
||||
EditCubit({DraftCache? draftCache})
|
||||
: _draftCache = draftCache ?? locator.get<DraftCache>(),
|
||||
_debouncer = Debouncer(delay: Durations.oneSecond),
|
||||
_debouncer = Debouncer(delay: AppDurations.oneSecond),
|
||||
super(const EditState.init());
|
||||
|
||||
final DraftCache _draftCache;
|
||||
|
@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
@ -12,13 +15,16 @@ class FavCubit extends Cubit<FavState> {
|
||||
required AuthBloc authBloc,
|
||||
AuthRepository? authRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
HackerNewsWebRepository? hackerNewsWebRepository,
|
||||
}) : _authBloc = authBloc,
|
||||
_authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
super(FavState.init()) {
|
||||
init();
|
||||
}
|
||||
@ -26,44 +32,42 @@ class FavCubit extends Cubit<FavState> {
|
||||
final AuthBloc _authBloc;
|
||||
final AuthRepository _authRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final HackerNewsWebRepository _hackerNewsWebRepository;
|
||||
late final StreamSubscription<String>? _usernameSubscription;
|
||||
static const int _pageSize = 20;
|
||||
String? _username;
|
||||
|
||||
Future<void> init() async {
|
||||
_authBloc.stream.listen((AuthState authState) {
|
||||
if (authState.username != _username) {
|
||||
_preferenceRepository
|
||||
.favList(of: authState.username)
|
||||
.then((List<int> favIds) {
|
||||
_usernameSubscription = _authBloc.stream
|
||||
.map((AuthState event) => event.username)
|
||||
.distinct()
|
||||
.listen((String username) {
|
||||
_preferenceRepository.favList(of: username).then((List<int> favIds) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
favIds: favIds,
|
||||
favItems: <Item>[],
|
||||
currentPage: 0,
|
||||
),
|
||||
);
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
|
||||
)
|
||||
.listen(_onItemLoaded)
|
||||
.onDone(() {
|
||||
emit(
|
||||
state.copyWith(
|
||||
favIds: favIds,
|
||||
favItems: <Item>[],
|
||||
currentPage: 0,
|
||||
status: Status.success,
|
||||
),
|
||||
);
|
||||
_storiesRepository
|
||||
.fetchItemsStream(
|
||||
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
|
||||
)
|
||||
.listen(_onItemLoaded)
|
||||
.onDone(() {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: Status.success,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
_username = authState.username;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> addFav(int id) async {
|
||||
final String username = _authBloc.state.username;
|
||||
if (state.favIds.contains(id)) return;
|
||||
|
||||
await _preferenceRepository.addFav(username: username, id: id);
|
||||
|
||||
@ -73,7 +77,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
),
|
||||
);
|
||||
|
||||
final Item? item = await _storiesRepository.fetchItem(id: id);
|
||||
final Item? item = await _hackerNewsRepository.fetchItem(id: id);
|
||||
|
||||
if (item == null) return;
|
||||
|
||||
@ -89,8 +93,6 @@ class FavCubit extends Cubit<FavState> {
|
||||
}
|
||||
|
||||
void removeFav(int id) {
|
||||
final String username = _authBloc.state.username;
|
||||
|
||||
_preferenceRepository.removeFav(username: username, id: id);
|
||||
|
||||
emit(
|
||||
@ -119,7 +121,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
upper = len;
|
||||
}
|
||||
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: state.favIds.sublist(
|
||||
lower,
|
||||
@ -136,8 +138,6 @@ class FavCubit extends Cubit<FavState> {
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
final String username = _authBloc.state.username;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: Status.inProgress,
|
||||
@ -149,7 +149,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
|
||||
_preferenceRepository.favList(of: username).then((List<int> favIds) {
|
||||
emit(state.copyWith(favIds: favIds));
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
|
||||
)
|
||||
@ -167,6 +167,23 @@ class FavCubit extends Cubit<FavState> {
|
||||
emit(FavState.init());
|
||||
}
|
||||
|
||||
Future<void> merge() async {
|
||||
if (_authBloc.state.isLoggedIn) {
|
||||
emit(state.copyWith(mergeStatus: Status.inProgress));
|
||||
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
|
||||
of: _authBloc.state.username,
|
||||
);
|
||||
final List<int> combinedIds = <int>[...ids, ...state.favIds];
|
||||
final LinkedHashSet<int> mergedIds = LinkedHashSet<int>.from(combinedIds);
|
||||
await _preferenceRepository.overwriteFav(
|
||||
username: username,
|
||||
ids: mergedIds,
|
||||
);
|
||||
emit(state.copyWith(mergeStatus: Status.success));
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void _onItemLoaded(Item item) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -174,4 +191,14 @@ class FavCubit extends Cubit<FavState> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_usernameSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
extension on FavCubit {
|
||||
String get username => _authBloc.state.username;
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ class FavState extends Equatable {
|
||||
required this.favIds,
|
||||
required this.favItems,
|
||||
required this.status,
|
||||
required this.mergeStatus,
|
||||
required this.currentPage,
|
||||
});
|
||||
|
||||
@ -12,23 +13,27 @@ class FavState extends Equatable {
|
||||
: favIds = <int>[],
|
||||
favItems = <Item>[],
|
||||
status = Status.idle,
|
||||
mergeStatus = Status.idle,
|
||||
currentPage = 0;
|
||||
|
||||
final List<int> favIds;
|
||||
final List<Item> favItems;
|
||||
final Status status;
|
||||
final Status mergeStatus;
|
||||
final int currentPage;
|
||||
|
||||
FavState copyWith({
|
||||
List<int>? favIds,
|
||||
List<Item>? favItems,
|
||||
Status? status,
|
||||
Status? mergeStatus,
|
||||
int? currentPage,
|
||||
}) {
|
||||
return FavState(
|
||||
favIds: favIds ?? this.favIds,
|
||||
favItems: favItems ?? this.favItems,
|
||||
status: status ?? this.status,
|
||||
mergeStatus: mergeStatus ?? this.mergeStatus,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
);
|
||||
}
|
||||
@ -36,6 +41,7 @@ class FavState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
mergeStatus,
|
||||
currentPage,
|
||||
favIds,
|
||||
favItems,
|
||||
|
@ -10,16 +10,16 @@ part 'history_state.dart';
|
||||
class HistoryCubit extends Cubit<HistoryState> {
|
||||
HistoryCubit({
|
||||
required AuthBloc authBloc,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
}) : _authBloc = authBloc,
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
super(HistoryState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final AuthBloc _authBloc;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
static const int _pageSize = 20;
|
||||
|
||||
void init() {
|
||||
@ -27,7 +27,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
if (authState.isLoggedIn) {
|
||||
final String username = authState.username;
|
||||
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchSubmitted(userId: username)
|
||||
.then((List<int>? submittedIds) {
|
||||
emit(
|
||||
@ -38,7 +38,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
),
|
||||
);
|
||||
if (submittedIds != null) {
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: submittedIds.sublist(
|
||||
0,
|
||||
@ -66,7 +66,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
upper = len;
|
||||
}
|
||||
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: state.submittedIds.sublist(
|
||||
lower,
|
||||
@ -93,12 +93,12 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
),
|
||||
);
|
||||
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchSubmitted(userId: username)
|
||||
.then((List<int>? submittedIds) {
|
||||
emit(state.copyWith(submittedIds: submittedIds));
|
||||
if (submittedIds != null) {
|
||||
_storiesRepository
|
||||
_hackerNewsRepository
|
||||
.fetchItemsStream(
|
||||
ids: submittedIds.sublist(
|
||||
0,
|
||||
|
@ -16,23 +16,26 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
NotificationCubit({
|
||||
required AuthBloc authBloc,
|
||||
required PreferenceCubit preferenceCubit,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
}) : _authBloc = authBloc,
|
||||
_preferenceCubit = preferenceCubit,
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
super(NotificationState.init()) {
|
||||
_authBloc.stream.listen((AuthState authState) {
|
||||
if (authState.isLoggedIn && authState.username != _username) {
|
||||
_authBloc.stream
|
||||
.map((AuthState event) => event.username)
|
||||
.distinct()
|
||||
.listen((String username) {
|
||||
if (username.isNotEmpty) {
|
||||
// Get the user setting.
|
||||
if (_preferenceCubit.state.notificationEnabled) {
|
||||
Future<void>.delayed(Durations.twoSeconds, init);
|
||||
Future<void>.delayed(AppDurations.twoSeconds, init);
|
||||
}
|
||||
|
||||
// Listen for setting changes in the future.
|
||||
@ -44,9 +47,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
_timer?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
_username = authState.username;
|
||||
} else if (!authState.isLoggedIn) {
|
||||
} else {
|
||||
emit(NotificationState.init());
|
||||
}
|
||||
});
|
||||
@ -54,10 +55,9 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
final AuthBloc _authBloc;
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
String? _username;
|
||||
Timer? _timer;
|
||||
|
||||
static const Duration _refreshInterval = Duration(minutes: 5);
|
||||
@ -82,7 +82,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
for (final int id in commentsToBeLoaded) {
|
||||
Comment? comment = await _sembastRepository.getComment(id: id);
|
||||
comment ??= await _storiesRepository.fetchComment(id: id);
|
||||
comment ??= await _hackerNewsRepository.fetchComment(id: id);
|
||||
if (comment != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -160,7 +160,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
for (final int id in commentsToBeLoaded) {
|
||||
Comment? comment = await _sembastRepository.getComment(id: id);
|
||||
comment ??= await _storiesRepository.fetchComment(id: id);
|
||||
comment ??= await _hackerNewsRepository.fetchComment(id: id);
|
||||
if (comment != null) {
|
||||
emit(state.copyWith(comments: <Comment>[...state.comments, comment]));
|
||||
}
|
||||
@ -184,7 +184,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
}
|
||||
|
||||
Future<void> _fetchReplies() {
|
||||
return _storiesRepository
|
||||
return _hackerNewsRepository
|
||||
.fetchSubmitted(userId: _authBloc.state.username)
|
||||
.then((List<int>? submittedItems) async {
|
||||
if (submittedItems != null) {
|
||||
@ -194,7 +194,9 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
);
|
||||
|
||||
for (final int id in subscribedItems) {
|
||||
await _storiesRepository.fetchItem(id: id).then((Item? item) async {
|
||||
await _hackerNewsRepository
|
||||
.fetchItem(id: id)
|
||||
.then((Item? item) async {
|
||||
final List<int> kids = item?.kids ?? <int>[];
|
||||
final List<int> previousKids =
|
||||
(await _sembastRepository.kids(of: id)) ?? <int>[];
|
||||
@ -216,7 +218,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
...state.unreadCommentsIds,
|
||||
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
|
||||
);
|
||||
await _storiesRepository
|
||||
await _hackerNewsRepository
|
||||
.fetchComment(id: newCommentId)
|
||||
.then((Comment? comment) {
|
||||
if (comment != null && !comment.dead && !comment.deleted) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
@ -9,28 +10,33 @@ part 'pin_state.dart';
|
||||
class PinCubit extends Cubit<PinState> {
|
||||
PinCubit({
|
||||
PreferenceRepository? preferenceRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
}) : _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
super(PinState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
|
||||
void init() {
|
||||
emit(PinState.init());
|
||||
_preferenceRepository.pinnedStoriesIds.then((List<int> ids) {
|
||||
emit(state.copyWith(pinnedStoriesIds: ids));
|
||||
|
||||
_storiesRepository.fetchStoriesStream(ids: ids).listen(_onStoryFetched);
|
||||
_hackerNewsRepository
|
||||
.fetchStoriesStream(ids: ids)
|
||||
.listen(_onStoryFetched);
|
||||
}).whenComplete(() => emit(state.copyWith(status: Status.success)));
|
||||
}
|
||||
|
||||
void pinStory(Story story) {
|
||||
void pinStory(
|
||||
Story story, {
|
||||
VoidCallback? onDone,
|
||||
}) {
|
||||
if (!state.pinnedStoriesIds.contains(story.id)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -39,10 +45,14 @@ class PinCubit extends Cubit<PinState> {
|
||||
),
|
||||
);
|
||||
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
|
||||
onDone?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void unpinStory(Story story) {
|
||||
void unpinStory(
|
||||
Story story, {
|
||||
VoidCallback? onDone,
|
||||
}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pinnedStoriesIds: <int>[...state.pinnedStoriesIds]..remove(story.id),
|
||||
@ -50,6 +60,7 @@ class PinCubit extends Cubit<PinState> {
|
||||
),
|
||||
);
|
||||
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
|
||||
onDone?.call();
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
|
@ -11,13 +11,13 @@ part 'poll_state.dart';
|
||||
class PollCubit extends Cubit<PollState> {
|
||||
PollCubit({
|
||||
required Story story,
|
||||
StoriesRepository? storiesRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
}) : _story = story,
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
super(PollState.init());
|
||||
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final Story _story;
|
||||
|
||||
Future<void> init({
|
||||
@ -33,7 +33,7 @@ class PollCubit extends Cubit<PollState> {
|
||||
|
||||
if (pollOptionsIds.isEmpty || refresh) {
|
||||
final Story? updatedStory =
|
||||
await _storiesRepository.fetchStory(id: _story.id);
|
||||
await _hackerNewsRepository.fetchStory(id: _story.id);
|
||||
|
||||
if (updatedStory != null) {
|
||||
pollOptionsIds = updatedStory.parts;
|
||||
@ -47,7 +47,7 @@ class PollCubit extends Cubit<PollState> {
|
||||
}
|
||||
|
||||
if (pollOptionsIds.isNotEmpty) {
|
||||
final List<PollOption> pollOptions = (await _storiesRepository
|
||||
final List<PollOption> pollOptions = (await _hackerNewsRepository
|
||||
.fetchPollOptionsStream(ids: pollOptionsIds)
|
||||
.toSet())
|
||||
.toList();
|
||||
|
@ -70,6 +70,10 @@ class PreferenceState extends Equatable {
|
||||
|
||||
bool get customTabEnabled => _isOn<CustomTabPreference>();
|
||||
|
||||
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
|
||||
|
||||
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
|
||||
|
||||
double get textScaleFactor =>
|
||||
preferences.singleWhereType<TextScaleFactorPreference>().val;
|
||||
|
||||
|
@ -102,6 +102,18 @@ class SearchCubit extends Cubit<SearchState> {
|
||||
search(state.params.query);
|
||||
}
|
||||
|
||||
void onExactMatchToggled() {
|
||||
emit(
|
||||
state.copyWith(
|
||||
params: state.params.copyWith(
|
||||
exactMatch: !state.params.exactMatch,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
search(state.params.query);
|
||||
}
|
||||
|
||||
void onDateTimeRangeUpdated(DateTime start, DateTime end) {
|
||||
final DateTime updatedStart = start.copyWith(
|
||||
second: 0,
|
||||
|
@ -7,16 +7,16 @@ import 'package:hacki/repositories/repositories.dart';
|
||||
part 'user_state.dart';
|
||||
|
||||
class UserCubit extends Cubit<UserState> {
|
||||
UserCubit({StoriesRepository? storiesRepository})
|
||||
: _storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
UserCubit({HackerNewsRepository? hackerNewsRepository})
|
||||
: _hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
super(const UserState.init());
|
||||
|
||||
final StoriesRepository _storiesRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
|
||||
void init({required String userId}) {
|
||||
emit(state.copyWith(status: Status.inProgress));
|
||||
_storiesRepository.fetchUser(id: userId).then((User? user) {
|
||||
_hackerNewsRepository.fetchUser(id: userId).then((User? user) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user ?? User.emptyWithId(userId),
|
||||
|
@ -20,7 +20,7 @@ extension ContextExtension on BuildContext {
|
||||
}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Theme.of(this).primaryColor,
|
||||
backgroundColor: Theme.of(this).colorScheme.primary,
|
||||
content: Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
@ -85,4 +85,8 @@ extension ContextExtension on BuildContext {
|
||||
int get storyTileMaxLines {
|
||||
return _storyTileMaxLines;
|
||||
}
|
||||
|
||||
double get topPadding {
|
||||
return MediaQuery.of(this).padding.top + kToolbarHeight;
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
@ -103,31 +104,26 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return SafeArea(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => context.pop(item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => context.pop(
|
||||
'https://news.ycombinator.com/item?id=${item.id}',
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => context.pop(item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => context.pop(
|
||||
'${Constants.hackerNewsItemLinkPrefix}${item.id}',
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
|
||||
linkToShare = '${Constants.hackerNewsItemLinkPrefix}${item.id}';
|
||||
}
|
||||
|
||||
if (linkToShare != null) {
|
||||
|
@ -19,6 +19,7 @@ import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/services/fetcher.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/haptic_feedback_util.dart';
|
||||
import 'package:hacki/utils/theme_util.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
@ -137,7 +138,7 @@ Future<void> main({bool testing = false}) async {
|
||||
|
||||
HydratedBloc.storage = storage;
|
||||
|
||||
VisibilityDetectorController.instance.updateInterval = Durations.ms200;
|
||||
VisibilityDetectorController.instance.updateInterval = AppDurations.ms200;
|
||||
|
||||
runApp(
|
||||
HackiApp(
|
||||
@ -229,14 +230,21 @@ class HackiApp extends StatelessWidget {
|
||||
)..init(),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
child: BlocConsumer<PreferenceCubit, PreferenceState>(
|
||||
listenWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.hapticFeedbackEnabled != current.hapticFeedbackEnabled,
|
||||
listener: (_, PreferenceState state) {
|
||||
HapticFeedbackUtil.enabled = state.hapticFeedbackEnabled;
|
||||
},
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.appColor != current.appColor ||
|
||||
previous.font != current.font ||
|
||||
previous.textScaleFactor != current.textScaleFactor,
|
||||
builder: (BuildContext context, PreferenceState state) {
|
||||
return AdaptiveTheme(
|
||||
key: ValueKey<String>('${state.appColor}${state.font}'),
|
||||
key: ValueKey<String>(
|
||||
'''${state.appColor}${state.font}''',
|
||||
),
|
||||
light: ThemeData(
|
||||
primaryColor: state.appColor,
|
||||
colorScheme: ColorScheme.fromSwatch(
|
||||
@ -251,7 +259,6 @@ class HackiApp extends StatelessWidget {
|
||||
primarySwatch: state.appColor,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
canvasColor: Palette.black,
|
||||
fontFamily: state.font.name,
|
||||
),
|
||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||
@ -275,19 +282,75 @@ class HackiApp extends StatelessWidget {
|
||||
.platformDispatcher
|
||||
.platformBrightness ==
|
||||
Brightness.dark);
|
||||
final ColorScheme colorScheme = ColorScheme.fromSeed(
|
||||
brightness:
|
||||
isDarkModeEnabled ? Brightness.dark : Brightness.light,
|
||||
seedColor: state.appColor,
|
||||
);
|
||||
return FeatureDiscovery(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaleFactor: state.textScaleFactor == 1
|
||||
? null
|
||||
: state.textScaleFactor,
|
||||
),
|
||||
data: state.textScaleFactor == 1
|
||||
? MediaQuery.of(context)
|
||||
: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(
|
||||
state.textScaleFactor,
|
||||
),
|
||||
),
|
||||
child: MaterialApp.router(
|
||||
key: Key(state.appColor.hashCode.toString()),
|
||||
title: 'Hacki',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith(
|
||||
useMaterial3: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
brightness: isDarkModeEnabled
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
seedColor: state.appColor,
|
||||
),
|
||||
fontFamily: state.font.name,
|
||||
dividerTheme: DividerThemeData(
|
||||
color: Palette.grey.withOpacity(0.2),
|
||||
),
|
||||
switchTheme: SwitchThemeData(
|
||||
trackColor: MaterialStateProperty.resolveWith(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return colorScheme.primary.withOpacity(0.6);
|
||||
} else {
|
||||
return Palette.grey.withOpacity(0.2);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
modalElevation: 8,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shadowColor: Palette.black,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: isDarkModeEnabled
|
||||
? Palette.white
|
||||
: Palette.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
sliderTheme: SliderThemeData(
|
||||
inactiveTrackColor:
|
||||
colorScheme.primary.withOpacity(0.5),
|
||||
activeTrackColor: colorScheme.primary,
|
||||
thumbColor: colorScheme.primary,
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
side: MaterialStateBorderSide.resolveWith(
|
||||
(_) => const BorderSide(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
routerConfig: router,
|
||||
),
|
||||
|
@ -6,4 +6,7 @@ enum CommentsOrder {
|
||||
const CommentsOrder(this.description);
|
||||
|
||||
final String description;
|
||||
|
||||
@override
|
||||
String toString() => description;
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ enum DiscoverableFeature {
|
||||
addStoryToFavList(
|
||||
featureId: 'add_story_to_fav_list',
|
||||
title: 'Fav a Story',
|
||||
description: '''Add it to your favorites''',
|
||||
description: '''Add it to your favorites.''',
|
||||
),
|
||||
openStoryInWebView(
|
||||
featureId: 'open_story_in_web_view',
|
||||
@ -25,13 +25,18 @@ enum DiscoverableFeature {
|
||||
featureId: 'jump_up_button_with_long_press',
|
||||
title: 'Shortcut',
|
||||
description:
|
||||
'''Tapping on this button will take you to the previous off-screen root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
|
||||
'''Tapping on this button will take you to the previous root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
|
||||
),
|
||||
jumpDownButton(
|
||||
featureId: 'jump_down_button_with_long_press',
|
||||
title: 'Shortcut',
|
||||
description:
|
||||
'''Tapping on this button will take you to the next off-screen root level comment.\n\nLong press on it to jump to the end of this thread.''',
|
||||
'''Tapping on this button will take you to the next root level comment.\n\nLong press on it to jump to the end of this thread.''',
|
||||
),
|
||||
searchInThread(
|
||||
featureId: 'search_in_thread',
|
||||
title: 'Search in Thread',
|
||||
description: '''Search for comments in this thread.''',
|
||||
);
|
||||
|
||||
const DiscoverableFeature({
|
||||
|
@ -5,4 +5,7 @@ enum FetchMode {
|
||||
const FetchMode(this.description);
|
||||
|
||||
final String description;
|
||||
|
||||
@override
|
||||
String toString() => description;
|
||||
}
|
||||
|
@ -3,26 +3,11 @@ enum Font {
|
||||
robotoSlab('Roboto Slab', isSerif: true),
|
||||
ubuntu('Ubuntu'),
|
||||
ubuntuMono('Ubuntu Mono'),
|
||||
notoSerif('Noto Serif', isSerif: true);
|
||||
notoSerif('Noto Serif', isSerif: true),
|
||||
exo2('Exo 2');
|
||||
|
||||
const Font(this.uiLabel, {this.isSerif = false});
|
||||
|
||||
final String uiLabel;
|
||||
final bool isSerif;
|
||||
|
||||
static Font fromString(String? val) {
|
||||
switch (val) {
|
||||
case 'robotoSlab':
|
||||
return Font.robotoSlab;
|
||||
case 'ubuntu':
|
||||
return Font.ubuntu;
|
||||
case 'ubuntuMono':
|
||||
return Font.ubuntuMono;
|
||||
case 'notoSerif':
|
||||
return Font.notoSerif;
|
||||
case 'roboto':
|
||||
default:
|
||||
return Font.roboto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ class BuildableComment extends Comment with Buildable {
|
||||
required super.deleted,
|
||||
required super.hidden,
|
||||
required super.level,
|
||||
required super.isFromCache,
|
||||
required this.elements,
|
||||
});
|
||||
|
||||
@ -33,6 +34,7 @@ class BuildableComment extends Comment with Buildable {
|
||||
deleted: comment.deleted,
|
||||
level: comment.level,
|
||||
hidden: comment.hidden,
|
||||
isFromCache: comment.isFromCache,
|
||||
);
|
||||
|
||||
@override
|
||||
@ -53,6 +55,7 @@ class BuildableComment extends Comment with Buildable {
|
||||
hidden: hidden ?? this.hidden,
|
||||
level: level ?? this.level,
|
||||
elements: elements,
|
||||
isFromCache: isFromCache,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ class Comment extends Item {
|
||||
required super.deleted,
|
||||
required super.hidden,
|
||||
required this.level,
|
||||
required this.isFromCache,
|
||||
}) : super(
|
||||
descendants: 0,
|
||||
parts: <int>[],
|
||||
@ -21,9 +22,12 @@ class Comment extends Item {
|
||||
type: '',
|
||||
);
|
||||
|
||||
Comment.fromJson(super.json, {this.level = 0}) : super.fromJson();
|
||||
Comment.fromJson(super.json, {this.level = 0})
|
||||
: isFromCache = json['fromCache'] == true,
|
||||
super.fromJson();
|
||||
|
||||
final int level;
|
||||
final bool isFromCache;
|
||||
|
||||
String get metadata => '''by $by $timeAgo''';
|
||||
|
||||
@ -45,6 +49,7 @@ class Comment extends Item {
|
||||
deleted: deleted,
|
||||
hidden: hidden ?? this.hidden,
|
||||
level: level ?? this.level,
|
||||
isFromCache: isFromCache,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -37,11 +37,13 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
const MarkReadStoriesModePreference(),
|
||||
// Divider.
|
||||
const NotificationModePreference(),
|
||||
const SwipeGesturePreference(),
|
||||
const AutoScrollModePreference(),
|
||||
const CollapseModePreference(),
|
||||
const ReaderModePreference(),
|
||||
const CustomTabPreference(),
|
||||
const ManualPaginationPreference(),
|
||||
const SwipeGesturePreference(),
|
||||
const HapticFeedbackPreference(),
|
||||
const EyeCandyModePreference(),
|
||||
],
|
||||
);
|
||||
@ -66,6 +68,7 @@ const bool _notificationModeDefaultValue = true;
|
||||
const bool _swipeGestureModeDefaultValue = false;
|
||||
const bool _displayModeDefaultValue = true;
|
||||
const bool _eyeCandyModeDefaultValue = false;
|
||||
const bool _hapticFeedbackModeDefaultValue = true;
|
||||
const bool _readerModeDefaultValue = true;
|
||||
const bool _markReadStoriesModeDefaultValue = true;
|
||||
const bool _metadataModeDefaultValue = true;
|
||||
@ -73,12 +76,13 @@ const bool _storyUrlModeDefaultValue = true;
|
||||
const bool _collapseModeDefaultValue = true;
|
||||
const bool _autoScrollModeDefaultValue = false;
|
||||
const bool _customTabModeDefaultValue = false;
|
||||
const bool _paginationModeDefaultValue = false;
|
||||
const double _textScaleFactorDefaultValue = 1;
|
||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||
final int _fontSizeDefaultValue = FontSize.regular.index;
|
||||
final int _appColorDefaultValue = materialColors.indexOf(Palette.deepOrange);
|
||||
final int _fontDefaultValue = Font.roboto.index;
|
||||
final int _fontDefaultValue = Font.robotoSlab.index;
|
||||
final int _tabOrderDefaultValue =
|
||||
StoryType.convertToSettingsValue(StoryType.values);
|
||||
final int _markStoriesAsReadWhenPreferenceDefaultValue =
|
||||
@ -97,7 +101,7 @@ class SwipeGesturePreference extends BooleanPreference {
|
||||
String get key => 'swipeGestureMode';
|
||||
|
||||
@override
|
||||
String get title => 'Enable Swipe Gesture';
|
||||
String get title => 'Swipe Gesture';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
@ -285,6 +289,25 @@ class EyeCandyModePreference extends BooleanPreference {
|
||||
String get subtitle => 'some sort of magic.';
|
||||
}
|
||||
|
||||
class ManualPaginationPreference extends BooleanPreference {
|
||||
const ManualPaginationPreference({bool? val})
|
||||
: super(val: val ?? _paginationModeDefaultValue);
|
||||
|
||||
@override
|
||||
ManualPaginationPreference copyWith({required bool? val}) {
|
||||
return ManualPaginationPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'paginationMode';
|
||||
|
||||
@override
|
||||
String get title => 'Manual Pagination';
|
||||
|
||||
@override
|
||||
String get subtitle => '''so you can get stuff done.''';
|
||||
}
|
||||
|
||||
/// Whether or not to use Custom Tabs for launching URLs.
|
||||
/// If false, default browser will be used.
|
||||
///
|
||||
@ -312,6 +335,28 @@ class CustomTabPreference extends BooleanPreference {
|
||||
bool get isDisplayable => Platform.isAndroid;
|
||||
}
|
||||
|
||||
class HapticFeedbackPreference extends BooleanPreference {
|
||||
const HapticFeedbackPreference({bool? val})
|
||||
: super(val: val ?? _hapticFeedbackModeDefaultValue);
|
||||
|
||||
@override
|
||||
HapticFeedbackPreference copyWith({required bool? val}) {
|
||||
return HapticFeedbackPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'hapticFeedbackMode';
|
||||
|
||||
@override
|
||||
String get title => 'Haptic Feedback';
|
||||
|
||||
@override
|
||||
String get subtitle => '';
|
||||
|
||||
@override
|
||||
bool get isDisplayable => Platform.isIOS;
|
||||
}
|
||||
|
||||
class FetchModePreference extends IntPreference {
|
||||
FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
|
||||
|
||||
@ -401,7 +446,7 @@ class StoryMarkingModePreference extends IntPreference {
|
||||
String get key => 'storyMarkingMode';
|
||||
|
||||
@override
|
||||
String get title => 'Mark a Story as Read on';
|
||||
String get title => 'Mark as Read on';
|
||||
}
|
||||
|
||||
class AppColorPreference extends IntPreference {
|
||||
|
@ -8,31 +8,36 @@ class SearchParams extends Equatable {
|
||||
required this.filters,
|
||||
required this.query,
|
||||
required this.page,
|
||||
this.sorted = false,
|
||||
required this.sorted,
|
||||
required this.exactMatch,
|
||||
});
|
||||
|
||||
SearchParams.init()
|
||||
: filters = <SearchFilter>{},
|
||||
query = '',
|
||||
page = 0,
|
||||
sorted = false;
|
||||
sorted = false,
|
||||
exactMatch = false;
|
||||
|
||||
final Set<SearchFilter> filters;
|
||||
final String query;
|
||||
final int page;
|
||||
final bool sorted;
|
||||
final bool exactMatch;
|
||||
|
||||
SearchParams copyWith({
|
||||
Set<SearchFilter>? filters,
|
||||
String? query,
|
||||
int? page,
|
||||
bool? sorted,
|
||||
bool? exactMatch,
|
||||
}) {
|
||||
return SearchParams(
|
||||
filters: filters ?? this.filters,
|
||||
query: query ?? this.query,
|
||||
page: page ?? this.page,
|
||||
sorted: sorted ?? this.sorted,
|
||||
exactMatch: exactMatch ?? this.exactMatch,
|
||||
);
|
||||
}
|
||||
|
||||
@ -43,6 +48,7 @@ class SearchParams extends Equatable {
|
||||
query: query,
|
||||
page: page,
|
||||
sorted: sorted,
|
||||
exactMatch: exactMatch,
|
||||
);
|
||||
}
|
||||
|
||||
@ -54,16 +60,19 @@ class SearchParams extends Equatable {
|
||||
query: query,
|
||||
page: page,
|
||||
sorted: sorted,
|
||||
exactMatch: exactMatch,
|
||||
);
|
||||
}
|
||||
|
||||
String get filteredQuery {
|
||||
final StringBuffer buffer = StringBuffer();
|
||||
final String encodedQuery =
|
||||
Uri.encodeComponent(exactMatch ? '"$query"' : query);
|
||||
|
||||
if (sorted) {
|
||||
buffer.write('search_by_date?query=${Uri.encodeComponent(query)}');
|
||||
buffer.write('search_by_date?query=$encodedQuery');
|
||||
} else {
|
||||
buffer.write('search?query=${Uri.encodeComponent(query)}');
|
||||
buffer.write('search?query=$encodedQuery');
|
||||
}
|
||||
|
||||
final Iterable<NumericFilter> numericFilters =
|
||||
@ -111,5 +120,6 @@ class SearchParams extends Equatable {
|
||||
query,
|
||||
page,
|
||||
sorted,
|
||||
exactMatch,
|
||||
];
|
||||
}
|
||||
|
@ -6,7 +6,8 @@ enum StoryMarkingMode {
|
||||
tap('tapping'),
|
||||
// Mark a story as read after user scrolls past or taps on it, whichever
|
||||
// happens the first.
|
||||
scrollPastOrTap('scrolling past or tapping');
|
||||
scrollPastOrTap('scrolling past or tapping'),
|
||||
swipeGestureOnly('swipe gesture only');
|
||||
|
||||
const StoryMarkingMode(this.label);
|
||||
|
||||
|
@ -1,19 +1,31 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// [StoriesRepository] is for fetching
|
||||
/// [HackerNewsRepository] is for fetching
|
||||
/// [Item] such as [Story], [PollOption], [Comment] or [User].
|
||||
///
|
||||
/// You can learn more about the Hacker News API at
|
||||
/// https://github.com/HackerNews/API.
|
||||
class StoriesRepository {
|
||||
StoriesRepository({
|
||||
class HackerNewsRepository {
|
||||
HackerNewsRepository({
|
||||
FirebaseClient? firebaseClient,
|
||||
}) : _firebaseClient = firebaseClient ?? FirebaseClient.anonymous();
|
||||
SembastRepository? sembastRepository,
|
||||
Logger? logger,
|
||||
}) : _firebaseClient = firebaseClient ?? FirebaseClient.anonymous(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>();
|
||||
|
||||
final FirebaseClient _firebaseClient;
|
||||
final SembastRepository _sembastRepository;
|
||||
final Logger _logger;
|
||||
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
|
||||
|
||||
Future<Map<String, dynamic>?> _fetchItemJson(int id) async {
|
||||
@ -34,11 +46,10 @@ class StoriesRepository {
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
|
||||
final String type = json['type'] as String;
|
||||
if (type == 'story' || type == 'job' || type == 'poll') {
|
||||
if (json.isStory) {
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (type == 'comment') {
|
||||
} else if (json.isComment) {
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
@ -57,11 +68,10 @@ class StoriesRepository {
|
||||
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
|
||||
final String type = json['type'] as String;
|
||||
if (type == 'story' || type == 'job' || type == 'poll') {
|
||||
if (json.isStory) {
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (type == 'comment') {
|
||||
} else if (json.isComment) {
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
@ -213,6 +223,9 @@ class StoriesRepository {
|
||||
|
||||
/// Fetch a list of [Comment] based on ids and return results
|
||||
/// using a stream.
|
||||
///
|
||||
/// this function caches every comment fetched to [SembastRepository] so that
|
||||
/// we don't need to parse the text again later.
|
||||
Stream<Comment> fetchCommentsStream({
|
||||
required List<int> ids,
|
||||
int level = 0,
|
||||
@ -226,7 +239,17 @@ class StoriesRepository {
|
||||
if (json == null) return null;
|
||||
|
||||
final Comment comment = Comment.fromJson(json, level: level);
|
||||
|
||||
if (!json.isFromCache) {
|
||||
unawaited(_sembastRepository.cacheComment(comment));
|
||||
}
|
||||
|
||||
return comment;
|
||||
}).onError((Object? error, StackTrace stackTrace) {
|
||||
_logger.e(error, stackTrace: stackTrace);
|
||||
return _sembastRepository
|
||||
.getCachedComment(id: id)
|
||||
.then((Comment? value) => value?.copyWith(level: level));
|
||||
});
|
||||
|
||||
if (comment != null) {
|
||||
@ -238,6 +261,9 @@ class StoriesRepository {
|
||||
|
||||
/// Fetch a list of [Comment] based on ids recursively and
|
||||
/// return results using a stream.
|
||||
///
|
||||
/// this function caches every comment fetched to [SembastRepository] so that
|
||||
/// we don't need to parse the text again later.
|
||||
Stream<Comment> fetchAllCommentsRecursivelyStream({
|
||||
required List<int> ids,
|
||||
int level = 0,
|
||||
@ -251,7 +277,17 @@ class StoriesRepository {
|
||||
if (json == null) return null;
|
||||
|
||||
final Comment comment = Comment.fromJson(json, level: level);
|
||||
|
||||
if (!json.isFromCache) {
|
||||
unawaited(_sembastRepository.cacheComment(comment));
|
||||
}
|
||||
|
||||
return comment;
|
||||
}).onError((Object? error, StackTrace stackTrace) {
|
||||
_logger.e(error, stackTrace: stackTrace);
|
||||
return _sembastRepository
|
||||
.getCachedComment(id: id)
|
||||
.then((Comment? value) => value?.copyWith(level: level));
|
||||
});
|
||||
|
||||
if (comment != null) {
|
||||
@ -275,11 +311,10 @@ class StoriesRepository {
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
|
||||
if (json == null) return null;
|
||||
|
||||
final String type = json['type'] as String;
|
||||
if (type == 'story' || type == 'job') {
|
||||
if (json.isStory) {
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (type == 'comment') {
|
||||
} else if (json.isComment) {
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
@ -343,12 +378,57 @@ class StoriesRepository {
|
||||
Map<String, dynamic>? json,
|
||||
) async {
|
||||
if (json == null) return null;
|
||||
final String text = json['text'] as String? ?? '';
|
||||
final String parsedText = await compute<String, String>(
|
||||
HtmlUtil.parseHtml,
|
||||
text,
|
||||
);
|
||||
json['text'] = parsedText;
|
||||
final int? itemId = json.itemId;
|
||||
|
||||
String? cachedText;
|
||||
if (json.isComment && itemId != null) {
|
||||
cachedText =
|
||||
(await locator.get<SembastRepository>().getCachedComment(id: itemId))
|
||||
?.text;
|
||||
}
|
||||
|
||||
bool isValid(String? text) {
|
||||
return cachedText != null &&
|
||||
cachedText != '[delayed]' &&
|
||||
cachedText != '[flagged]';
|
||||
}
|
||||
|
||||
if (isValid(cachedText)) {
|
||||
json
|
||||
..text = cachedText
|
||||
..isFromCache = true;
|
||||
} else {
|
||||
final String? text = json.text;
|
||||
if (text == null || text.isEmpty) return json;
|
||||
final String parsedText = await compute<String, String>(
|
||||
HtmlUtil.parseHtml,
|
||||
text,
|
||||
);
|
||||
json.text = parsedText;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
extension on Map<String, dynamic> {
|
||||
bool get isFromCache => this['fromCache'] == true;
|
||||
|
||||
set isFromCache(bool value) {
|
||||
this['fromCache'] = value;
|
||||
}
|
||||
|
||||
String? get text => this['text'] as String?;
|
||||
|
||||
set text(String? value) {
|
||||
this['text'] = value;
|
||||
}
|
||||
|
||||
int? get itemId => this['id'] as int?;
|
||||
|
||||
String? get type => this['type'] as String?;
|
||||
|
||||
bool get isStory => type == 'story' || type == 'job' || type == 'poll';
|
||||
|
||||
bool get isComment => type == 'comment';
|
||||
}
|
45
lib/repositories/hacker_news_web_repository.dart
Normal file
@ -0,0 +1,45 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
|
||||
/// For fetching anything that cannot be fetched through Hacker News API.
|
||||
class HackerNewsWebRepository {
|
||||
HackerNewsWebRepository();
|
||||
|
||||
static const String _favoritesBaseUrl =
|
||||
'https://news.ycombinator.com/favorites?id=';
|
||||
static const String _aThingSelector =
|
||||
'#hnmain > tbody > tr:nth-child(3) > td > table > tbody > .athing';
|
||||
|
||||
Future<Iterable<int>> fetchFavorites({required String of}) async {
|
||||
final String username = of;
|
||||
final List<int> allIds = <int>[];
|
||||
int page = 0;
|
||||
|
||||
Future<Iterable<int>> fetchIds(int page) async {
|
||||
final Uri url = Uri.parse('$_favoritesBaseUrl$username&p=$page');
|
||||
final Response response = await get(url);
|
||||
final Document document = parse(response.body);
|
||||
final List<Element> elements = document.querySelectorAll(_aThingSelector);
|
||||
final Iterable<int> parsedIds = elements
|
||||
.map(
|
||||
(Element e) => int.tryParse(e.id),
|
||||
)
|
||||
.whereNotNull();
|
||||
return parsedIds;
|
||||
}
|
||||
|
||||
Iterable<int> ids;
|
||||
while (true) {
|
||||
ids = await fetchIds(page);
|
||||
if (ids.isEmpty) {
|
||||
break;
|
||||
}
|
||||
allIds.addAll(ids);
|
||||
page++;
|
||||
}
|
||||
|
||||
return allIds;
|
||||
}
|
||||
}
|
@ -185,7 +185,9 @@ class OfflineRepository {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
final Comment comment = Comment.fromJson(json.cast<String, dynamic>());
|
||||
final Map<String, dynamic> typedJson = json.cast<String, dynamic>();
|
||||
typedJson['fromCache'] = true;
|
||||
final Comment comment = Comment.fromJson(typedJson);
|
||||
return comment;
|
||||
} catch (_) {
|
||||
_logger.e(_);
|
||||
@ -204,8 +206,9 @@ class OfflineRepository {
|
||||
final Map<dynamic, dynamic>? json = await box.get(id.toString());
|
||||
|
||||
if (json != null) {
|
||||
final Comment comment =
|
||||
Comment.fromJson(json.cast<String, dynamic>(), level: level);
|
||||
final Map<String, dynamic> typedJson = json.cast<String, dynamic>();
|
||||
typedJson['fromCache'] = true;
|
||||
final Comment comment = Comment.fromJson(typedJson, level: level);
|
||||
|
||||
yield comment;
|
||||
yield* getCachedCommentsStream(ids: comment.kids, level: level + 1);
|
||||
|
@ -157,7 +157,6 @@ class PreferenceRepository {
|
||||
((prefs.getStringList(_getFavKey('')) ?? <String>[])
|
||||
..addAll(prefs.getStringList(_getFavKey(of)) ?? <String>[]))
|
||||
.map(int.parse)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
return favList;
|
||||
@ -175,7 +174,7 @@ class PreferenceRepository {
|
||||
|
||||
await _syncedPrefs.setStringList(
|
||||
key: key,
|
||||
val: favList.map((int e) => e.toString()).toSet().toList(),
|
||||
val: favList.map((int e) => e.toString()).toList(),
|
||||
);
|
||||
} else {
|
||||
final SharedPreferences prefs = await _prefs;
|
||||
@ -186,7 +185,30 @@ class PreferenceRepository {
|
||||
|
||||
await prefs.setStringList(
|
||||
key,
|
||||
favList.map((int e) => e.toString()).toSet().toList(),
|
||||
favList.map((int e) => e.toString()).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> overwriteFav({
|
||||
required String username,
|
||||
required Iterable<int> ids,
|
||||
}) async {
|
||||
final String key = _getFavKey(username);
|
||||
final List<String> favList =
|
||||
ids.map((int e) => e.toString()).toList(growable: false);
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await _syncedPrefs.setStringList(
|
||||
key: key,
|
||||
val: favList,
|
||||
);
|
||||
} else {
|
||||
final SharedPreferences prefs = await _prefs;
|
||||
|
||||
await prefs.setStringList(
|
||||
key,
|
||||
favList,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -384,7 +406,7 @@ class PreferenceRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateHasRead(int storyId) async {
|
||||
Future<void> addHasRead(int storyId) async {
|
||||
final String key = _getHasReadKey(storyId);
|
||||
if (Platform.isIOS) {
|
||||
await _syncedPrefs.setBool(key: key, val: true);
|
||||
@ -398,6 +420,17 @@ class PreferenceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeHasRead(int storyId) async {
|
||||
final String key = _getHasReadKey(storyId);
|
||||
if (Platform.isIOS) {
|
||||
await _syncedPrefs.remove(key: key);
|
||||
} else {
|
||||
final SharedPreferences prefs = await _prefs;
|
||||
|
||||
await prefs.remove(_getHasReadKey(storyId));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAllReadStories() async {
|
||||
if (Platform.isIOS) {
|
||||
await _syncedPrefs.clearAll();
|
||||
|
@ -1,7 +1,8 @@
|
||||
export 'auth_repository.dart';
|
||||
export 'hacker_news_repository.dart';
|
||||
export 'hacker_news_web_repository.dart';
|
||||
export 'offline_repository.dart';
|
||||
export 'post_repository.dart';
|
||||
export 'preference_repository.dart';
|
||||
export 'search_repository.dart';
|
||||
export 'sembast_repository.dart';
|
||||
export 'stories_repository.dart';
|
||||
|
@ -60,6 +60,7 @@ class SearchRepository {
|
||||
deleted: false,
|
||||
hidden: false,
|
||||
level: 0,
|
||||
isFromCache: false,
|
||||
);
|
||||
yield comment;
|
||||
} else {
|
||||
|
@ -49,9 +49,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
super.didPopNext();
|
||||
if (context.read<StoriesBloc>().deviceScreenType ==
|
||||
DeviceScreenType.mobile) {
|
||||
locator.get<Logger>().i('resetting comments in CommentCache');
|
||||
locator.get<Logger>().i('Resetting comments in CommentCache');
|
||||
Future<void>.delayed(
|
||||
Durations.ms500,
|
||||
AppDurations.ms500,
|
||||
locator.get<CommentCache>().resetComments,
|
||||
);
|
||||
}
|
||||
@ -141,15 +141,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
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,
|
||||
),
|
||||
CustomTabBar(
|
||||
tabController: tabController,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -196,12 +189,13 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
void onStoryTapped(Story story) {
|
||||
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
|
||||
final PreferenceState prefState = context.read<PreferenceCubit>().state;
|
||||
final bool useReader = prefState.readerEnabled;
|
||||
final StoryMarkingMode storyMarkingMode = prefState.storyMarkingMode;
|
||||
final bool offlineReading =
|
||||
context.read<StoriesBloc>().state.isOfflineReading;
|
||||
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
|
||||
final StoryMarkingMode storyMarkingMode =
|
||||
context.read<PreferenceCubit>().state.storyMarkingMode;
|
||||
final bool markReadStoriesEnabled = prefState.markReadStoriesEnabled;
|
||||
|
||||
// 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.
|
||||
@ -210,7 +204,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (isJobWithLink) {
|
||||
context.read<ReminderCubit>().removeLastReadStoryId();
|
||||
} else {
|
||||
final ItemScreenArgs args = ItemScreenArgs(item: story);
|
||||
final bool shouldMarkNewComment = markReadStoriesEnabled &&
|
||||
context.read<StoriesBloc>().state.readStoriesIds.contains(story.id);
|
||||
final ItemScreenArgs args = ItemScreenArgs(
|
||||
item: story,
|
||||
shouldMarkNewComment: shouldMarkNewComment,
|
||||
);
|
||||
|
||||
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
|
||||
|
||||
@ -230,7 +229,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
if (storyMarkingMode.shouldDetectTapping) {
|
||||
if (markReadStoriesEnabled && storyMarkingMode.shouldDetectTapping) {
|
||||
context.read<StoriesBloc>().add(StoryRead(story: story));
|
||||
}
|
||||
|
||||
@ -253,7 +252,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
final int? id = event.itemId;
|
||||
|
||||
if (id != null) {
|
||||
locator.get<StoriesRepository>().fetchItem(id: id).then((Item? item) {
|
||||
locator.get<HackerNewsRepository>().fetchItem(id: id).then((Item? item) {
|
||||
if (mounted) {
|
||||
if (item != null) {
|
||||
goToItemScreen(
|
||||
@ -272,7 +271,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (storyId == null) return;
|
||||
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.get<HackerNewsRepository>()
|
||||
.fetchStory(id: storyId)
|
||||
.then((Story? story) {
|
||||
if (story == null) {
|
||||
@ -297,7 +296,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
context.read<NotificationCubit>().markAsRead(commentId);
|
||||
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.get<HackerNewsRepository>()
|
||||
.fetchStory(id: storyId)
|
||||
.then((Story? story) {
|
||||
if (story == null) {
|
||||
|
@ -45,7 +45,8 @@ class PinnedStories extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.2),
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
child: StoryTile(
|
||||
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
|
||||
story: story,
|
||||
@ -61,7 +62,7 @@ class PinnedStories extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Dimens.pt12),
|
||||
child: Divider(
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.8),
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -37,7 +37,7 @@ class TabletHomeScreen extends StatelessWidget {
|
||||
top: Dimens.zero,
|
||||
bottom: Dimens.zero,
|
||||
width: homeScreenWidth,
|
||||
duration: Durations.ms300,
|
||||
duration: AppDurations.ms300,
|
||||
curve: Curves.elasticOut,
|
||||
child: homeScreen,
|
||||
),
|
||||
@ -53,7 +53,7 @@ class TabletHomeScreen extends StatelessWidget {
|
||||
top: Dimens.zero,
|
||||
bottom: Dimens.zero,
|
||||
left: state.expanded ? Dimens.zero : homeScreenWidth,
|
||||
duration: Durations.ms300,
|
||||
duration: AppDurations.ms300,
|
||||
curve: Curves.elasticOut,
|
||||
child: const _TabletStoryView(),
|
||||
),
|
||||
|
@ -24,23 +24,26 @@ class ItemScreenArgs extends Equatable {
|
||||
const ItemScreenArgs({
|
||||
required this.item,
|
||||
this.onlyShowTargetComment = false,
|
||||
this.shouldMarkNewComment = false,
|
||||
this.useCommentCache = false,
|
||||
this.targetComments,
|
||||
});
|
||||
|
||||
final Item item;
|
||||
final bool onlyShowTargetComment;
|
||||
final bool shouldMarkNewComment;
|
||||
final List<Comment>? targetComments;
|
||||
|
||||
/// when the user is trying to view a sub-thread from a main thread, we don't
|
||||
/// need to fetch comments from [StoriesRepository] since we have some, if not
|
||||
/// all, comments cached in [CommentCache].
|
||||
/// need to fetch comments from [HackerNewsRepository] since we have some,
|
||||
/// if not all, comments cached in [CommentCache].
|
||||
final bool useCommentCache;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
item,
|
||||
onlyShowTargetComment,
|
||||
shouldMarkNewComment,
|
||||
targetComments,
|
||||
useCommentCache,
|
||||
];
|
||||
@ -52,6 +55,7 @@ class ItemScreen extends StatefulWidget {
|
||||
required this.parentComments,
|
||||
super.key,
|
||||
this.splitViewEnabled = false,
|
||||
this.shouldMarkNewComment = false,
|
||||
});
|
||||
|
||||
static const String routeName = 'item';
|
||||
@ -81,21 +85,22 @@ class ItemScreen extends StatefulWidget {
|
||||
child: ItemScreen(
|
||||
item: args.item,
|
||||
parentComments: args.targetComments ?? <Comment>[],
|
||||
shouldMarkNewComment: args.shouldMarkNewComment,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget tablet(BuildContext context, ItemScreenArgs args) {
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
return PopScope(
|
||||
canPop: () {
|
||||
if (context.read<SplitViewCubit>().state.expanded) {
|
||||
context.read<SplitViewCubit>().zoom();
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}(),
|
||||
child: RepositoryProvider<CollapseCache>(
|
||||
create: (_) => CollapseCache(),
|
||||
lazy: false,
|
||||
@ -123,6 +128,7 @@ class ItemScreen extends StatefulWidget {
|
||||
item: args.item,
|
||||
parentComments: args.targetComments ?? <Comment>[],
|
||||
splitViewEnabled: true,
|
||||
shouldMarkNewComment: args.shouldMarkNewComment,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -130,6 +136,7 @@ class ItemScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
final bool splitViewEnabled;
|
||||
final bool shouldMarkNewComment;
|
||||
final Item item;
|
||||
final List<Comment> parentComments;
|
||||
|
||||
@ -142,9 +149,6 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
final TextEditingController commentEditingController =
|
||||
TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
final ScrollOffsetListener scrollOffsetListener =
|
||||
ScrollOffsetListener.create();
|
||||
final Throttle storyLinkTapThrottle = Throttle(
|
||||
@ -156,9 +160,9 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
final GlobalKey fontSizeIconButtonKey = GlobalKey();
|
||||
StreamSubscription<double>? scrollOffsetSubscription;
|
||||
|
||||
static const Duration _storyLinkTapThrottleDelay = Durations.twoSeconds;
|
||||
static const Duration _storyLinkTapThrottleDelay = AppDurations.twoSeconds;
|
||||
static const Duration _featureDiscoveryDismissThrottleDelay =
|
||||
Durations.oneSecond;
|
||||
AppDurations.oneSecond;
|
||||
|
||||
@override
|
||||
void didPop() {
|
||||
@ -182,6 +186,7 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
<String>{
|
||||
DiscoverableFeature.searchInThread.featureId,
|
||||
DiscoverableFeature.pinToTop.featureId,
|
||||
DiscoverableFeature.addStoryToFavList.featureId,
|
||||
DiscoverableFeature.openStoryInWebView.featureId,
|
||||
@ -218,8 +223,6 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double topPadding =
|
||||
MediaQuery.of(context).padding.top + kToolbarHeight;
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (BuildContext context, AuthState authState) {
|
||||
return MultiBlocListener(
|
||||
@ -272,15 +275,14 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
children: <Widget>[
|
||||
Positioned.fill(
|
||||
child: MainView(
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
scrollOffsetListener: scrollOffsetListener,
|
||||
commentEditingController: commentEditingController,
|
||||
authState: authState,
|
||||
topPadding: topPadding,
|
||||
topPadding: context.topPadding,
|
||||
splitViewEnabled: widget.splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
shouldMarkNewComment: widget.shouldMarkNewComment,
|
||||
),
|
||||
),
|
||||
BlocBuilder<SplitViewCubit, SplitViewState>(
|
||||
@ -298,6 +300,7 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: CustomAppBar(
|
||||
context: context,
|
||||
backgroundColor: Theme.of(context)
|
||||
.canvasColor
|
||||
.withOpacity(0.6),
|
||||
@ -313,24 +316,24 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
const Positioned(
|
||||
right: Dimens.pt12,
|
||||
bottom: Dimens.pt36,
|
||||
child: CustomFloatingActionButton(
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
),
|
||||
child: CustomFloatingActionButton(),
|
||||
),
|
||||
Positioned(
|
||||
bottom: Dimens.zero,
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: ReplyBox(
|
||||
splitViewEnabled: true,
|
||||
focusNode: focusNode,
|
||||
textEditingController: commentEditingController,
|
||||
onSendTapped: onSendTapped,
|
||||
onChanged: context.read<EditCubit>().onTextChanged,
|
||||
child: Material(
|
||||
child: ReplyBox(
|
||||
splitViewEnabled: true,
|
||||
focusNode: focusNode,
|
||||
textEditingController: commentEditingController,
|
||||
onSendTapped: onSendTapped,
|
||||
onChanged:
|
||||
context.read<EditCubit>().onTextChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -340,6 +343,7 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
extendBodyBehindAppBar: true,
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: CustomAppBar(
|
||||
context: context,
|
||||
backgroundColor:
|
||||
Theme.of(context).canvasColor.withOpacity(0.6),
|
||||
foregroundColor: Theme.of(context).iconTheme.color,
|
||||
@ -348,20 +352,16 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
fontSizeIconButtonKey: fontSizeIconButtonKey,
|
||||
),
|
||||
body: MainView(
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
scrollOffsetListener: scrollOffsetListener,
|
||||
commentEditingController: commentEditingController,
|
||||
authState: authState,
|
||||
topPadding: topPadding,
|
||||
topPadding: context.topPadding,
|
||||
splitViewEnabled: widget.splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
shouldMarkNewComment: widget.shouldMarkNewComment,
|
||||
),
|
||||
floatingActionButton: CustomFloatingActionButton(
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
),
|
||||
floatingActionButton: const CustomFloatingActionButton(),
|
||||
bottomSheet: ReplyBox(
|
||||
textEditingController: commentEditingController,
|
||||
focusNode: focusNode,
|
||||
@ -415,7 +415,7 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
fontSize: fontSize.fontSize,
|
||||
color:
|
||||
context.read<PreferenceCubit>().state.fontSize == fontSize
|
||||
? Theme.of(context).primaryColor
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
@ -437,42 +437,36 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return SafeArea(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
color: Palette.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.av_timer),
|
||||
title: const Text('View ancestors'),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
onTimeMachineActivated(comment);
|
||||
},
|
||||
enabled:
|
||||
comment.level > 0 && !(comment.dead || comment.deleted),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.list),
|
||||
title: const Text('View in separate thread'),
|
||||
onTap: () {
|
||||
locator.get<AppReviewService>().requestReview();
|
||||
context.pop();
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: comment,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
enabled: !(comment.dead || comment.deleted),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.av_timer),
|
||||
title: const Text('View ancestors'),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
onTimeMachineActivated(comment);
|
||||
},
|
||||
enabled:
|
||||
comment.level > 0 && !(comment.dead || comment.deleted),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.list),
|
||||
title: const Text('View in separate thread'),
|
||||
onTap: () {
|
||||
locator.get<AppReviewService>().requestReview();
|
||||
context.pop();
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: comment,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
enabled: !(comment.dead || comment.deleted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -7,6 +7,7 @@ import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class CustomAppBar extends AppBar {
|
||||
CustomAppBar({
|
||||
required BuildContext context,
|
||||
required Item item,
|
||||
required super.backgroundColor,
|
||||
required super.foregroundColor,
|
||||
@ -34,6 +35,7 @@ class CustomAppBar extends AppBar {
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
const InThreadSearchIconButton(),
|
||||
IconButton(
|
||||
key: fontSizeIconButtonKey,
|
||||
icon: Text(
|
||||
@ -43,8 +45,9 @@ class CustomAppBar extends AppBar {
|
||||
fontSize: TextDimens.pt18,
|
||||
fontFamily: FeatherIcons.type.fontFamily,
|
||||
package: FeatherIcons.type.fontPackage,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textScaleFactor: 1,
|
||||
textScaler: TextScaler.noScaling,
|
||||
),
|
||||
onPressed: onFontSizeTap,
|
||||
),
|
||||
|
@ -3,108 +3,93 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/context_extension.dart';
|
||||
import 'package:hacki/models/discoverable_feature.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class CustomFloatingActionButton extends StatelessWidget {
|
||||
const CustomFloatingActionButton({
|
||||
required this.itemScrollController,
|
||||
required this.itemPositionsListener,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ItemScrollController itemScrollController;
|
||||
final ItemPositionsListener itemPositionsListener;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CommentsCubit, CommentsState>(
|
||||
builder: (BuildContext context, CommentsState state) {
|
||||
return BlocBuilder<EditCubit, EditState>(
|
||||
buildWhen: (EditState previous, EditState current) =>
|
||||
previous.showReplyBox != current.showReplyBox,
|
||||
builder: (BuildContext context, EditState editState) {
|
||||
return AnimatedPadding(
|
||||
padding: editState.showReplyBox
|
||||
? const EdgeInsets.only(
|
||||
bottom: Dimens.replyBoxCollapsedHeight,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
duration: Durations.ms200,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
CustomDescribedFeatureOverlay(
|
||||
feature: DiscoverableFeature.jumpUpButton,
|
||||
contentLocation: ContentLocation.above,
|
||||
tapTarget: const Icon(
|
||||
return BlocBuilder<EditCubit, EditState>(
|
||||
buildWhen: (EditState previous, EditState current) =>
|
||||
previous.showReplyBox != current.showReplyBox,
|
||||
builder: (BuildContext context, EditState editState) {
|
||||
return AnimatedPadding(
|
||||
padding: editState.showReplyBox
|
||||
? const EdgeInsets.only(
|
||||
bottom: Dimens.replyBoxCollapsedHeight,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
duration: AppDurations.ms200,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
CustomDescribedFeatureOverlay(
|
||||
feature: DiscoverableFeature.jumpUpButton,
|
||||
contentLocation: ContentLocation.above,
|
||||
tapTarget: const Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: Palette.white,
|
||||
),
|
||||
child: InkWell(
|
||||
onLongPress: () =>
|
||||
context.read<CommentsCubit>().scrollTo(index: 0),
|
||||
child: FloatingActionButton.small(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
|
||||
/// Randomly generated string as heroTag to prevent
|
||||
/// default [FloatingActionButton] animation.
|
||||
heroTag: UniqueKey().hashCode,
|
||||
onPressed: () {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CommentsCubit>().scrollToPreviousRoot();
|
||||
},
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: Palette.white,
|
||||
),
|
||||
child: InkWell(
|
||||
onLongPress: () => itemScrollController.scrollTo(
|
||||
index: 0,
|
||||
duration: Durations.ms400,
|
||||
),
|
||||
child: FloatingActionButton.small(
|
||||
backgroundColor:
|
||||
Theme.of(context).scaffoldBackgroundColor,
|
||||
|
||||
/// Randomly generated string as heroTag to prevent
|
||||
/// default [FloatingActionButton] animation.
|
||||
heroTag: UniqueKey().hashCode,
|
||||
onPressed: () {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CommentsCubit>().scrollToPreviousRoot(
|
||||
itemScrollController,
|
||||
itemPositionsListener,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
CustomDescribedFeatureOverlay(
|
||||
feature: DiscoverableFeature.jumpDownButton,
|
||||
tapTarget: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Palette.white,
|
||||
),
|
||||
child: InkWell(
|
||||
onLongPress: () => itemScrollController.scrollTo(
|
||||
index: state.comments.length,
|
||||
duration: Durations.ms400,
|
||||
),
|
||||
child: FloatingActionButton.small(
|
||||
backgroundColor:
|
||||
Theme.of(context).scaffoldBackgroundColor,
|
||||
|
||||
/// Same as above.
|
||||
heroTag: UniqueKey().hashCode,
|
||||
onPressed: () {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CommentsCubit>().scrollToNextRoot(
|
||||
itemScrollController,
|
||||
itemPositionsListener,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
CustomDescribedFeatureOverlay(
|
||||
feature: DiscoverableFeature.jumpDownButton,
|
||||
tapTarget: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Palette.white,
|
||||
),
|
||||
child: InkWell(
|
||||
onLongPress: () {
|
||||
final CommentsCubit cubit = context.read<CommentsCubit>();
|
||||
cubit.scrollTo(index: cubit.state.comments.length);
|
||||
},
|
||||
child: FloatingActionButton.small(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
|
||||
/// Same as above.
|
||||
heroTag: UniqueKey().hashCode,
|
||||
onPressed: () {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CommentsCubit>().scrollToNextRoot(
|
||||
onError: () => context.showSnackBar(
|
||||
content: '''No more root level comment below.''',
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -30,8 +30,8 @@ class FavIconButton extends StatelessWidget {
|
||||
child: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFav
|
||||
? Theme.of(context).primaryColor
|
||||
: Theme.of(context).iconTheme.color,
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
|
211
lib/screens/item/widgets/in_thread_search_icon_button.dart
Normal file
@ -0,0 +1,211 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/cubits/comments/comments_cubit.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
class InThreadSearchIconButton extends StatelessWidget {
|
||||
const InThreadSearchIconButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OpenContainer(
|
||||
closedColor: Palette.transparent,
|
||||
openColor: Theme.of(context).canvasColor,
|
||||
closedShape: const CircleBorder(),
|
||||
closedElevation: 0,
|
||||
openElevation: 0,
|
||||
transitionType: ContainerTransitionType.fadeThrough,
|
||||
closedBuilder: (BuildContext context, void Function() action) {
|
||||
return CustomDescribedFeatureOverlay(
|
||||
tapTarget: const Icon(
|
||||
Icons.search,
|
||||
color: Palette.white,
|
||||
),
|
||||
feature: DiscoverableFeature.searchInThread,
|
||||
child: IconButton(
|
||||
tooltip: 'Search in thread',
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: action,
|
||||
),
|
||||
);
|
||||
},
|
||||
openBuilder: (_, void Function({Object? returnValue}) action) =>
|
||||
_InThreadSearchView(
|
||||
commentsCubit: context.read<CommentsCubit>(),
|
||||
action: action,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InThreadSearchView extends StatefulWidget {
|
||||
const _InThreadSearchView({
|
||||
required this.commentsCubit,
|
||||
required this.action,
|
||||
});
|
||||
|
||||
final CommentsCubit commentsCubit;
|
||||
final void Function({Object? returnValue}) action;
|
||||
|
||||
@override
|
||||
State<_InThreadSearchView> createState() => _InThreadSearchViewState();
|
||||
}
|
||||
|
||||
class _InThreadSearchViewState extends State<_InThreadSearchView> {
|
||||
final ScrollController scrollController = ScrollController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final TextEditingController textEditingController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
scrollController.addListener(onScroll);
|
||||
textEditingController.text = widget.commentsCubit.state.inThreadSearchQuery;
|
||||
if (textEditingController.text.isEmpty) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController
|
||||
..removeListener(onScroll)
|
||||
..dispose();
|
||||
focusNode.dispose();
|
||||
textEditingController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void onScroll() => focusNode.unfocus();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CommentsCubit>.value(
|
||||
value: widget.commentsCubit,
|
||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||
buildWhen: (CommentsState previous, CommentsState current) =>
|
||||
previous.matchedComments != current.matchedComments ||
|
||||
previous.inThreadSearchAuthor != current.inThreadSearchAuthor,
|
||||
builder: (BuildContext context, CommentsState state) {
|
||||
final AuthState authState = context.read<AuthBloc>().state;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).canvasColor,
|
||||
elevation: 0,
|
||||
leadingWidth: 0,
|
||||
leading: const SizedBox.shrink(),
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(bottom: Dimens.pt8),
|
||||
child: Flex(
|
||||
direction: Axis.horizontal,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: textEditingController,
|
||||
focusNode: focusNode,
|
||||
cursorColor: Theme.of(context).colorScheme.primary,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search in this thread',
|
||||
suffixText: '${state.matchedComments.length} results',
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (String text) => widget.commentsCubit.search(
|
||||
text,
|
||||
author: state.inThreadSearchAuthor,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
onPressed: widget.action,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
controller: scrollController,
|
||||
shrinkWrap: true,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt12,
|
||||
),
|
||||
CustomChip(
|
||||
selected: state.inThreadSearchAuthor == state.item.by,
|
||||
label: 'by OP',
|
||||
onSelected: (bool value) {
|
||||
if (value) {
|
||||
widget.commentsCubit.search(
|
||||
state.inThreadSearchQuery,
|
||||
author: state.item.by,
|
||||
);
|
||||
} else {
|
||||
widget.commentsCubit.search(
|
||||
state.inThreadSearchQuery,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt12,
|
||||
),
|
||||
if (authState.isLoggedIn)
|
||||
CustomChip(
|
||||
selected:
|
||||
state.inThreadSearchAuthor == authState.username,
|
||||
label: 'by me',
|
||||
onSelected: (bool value) {
|
||||
if (value) {
|
||||
widget.commentsCubit.search(
|
||||
state.inThreadSearchQuery,
|
||||
author: authState.username,
|
||||
);
|
||||
} else {
|
||||
widget.commentsCubit.search(
|
||||
state.inThreadSearchQuery,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final int i in state.matchedComments)
|
||||
CommentTile(
|
||||
index: i,
|
||||
comment: state.comments.elementAt(i),
|
||||
fetchMode: FetchMode.lazy,
|
||||
actionable: false,
|
||||
collapsable: false,
|
||||
onTap: () {
|
||||
widget.action();
|
||||
widget.commentsCubit.scrollTo(
|
||||
index: i + 1,
|
||||
alignment: 0.2,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/discoverable_feature.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
@ -27,7 +28,7 @@ class LinkIconButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'https://news.ycombinator.com/item?id=$storyId',
|
||||
'${Constants.hackerNewsItemLinkPrefix}$storyId',
|
||||
context,
|
||||
useHackiForHnLink: false,
|
||||
),
|
||||
|