Compare commits
57 Commits
Author | SHA1 | Date | |
---|---|---|---|
b9636349d7 | |||
2028015977 | |||
b0225303f3 | |||
cf366927bc | |||
26ffe7535f | |||
b1e3ba5b0a | |||
c71952c098 | |||
5fdfd42fda | |||
823021c673 | |||
55f5e33210 | |||
ee79bbe5c3 | |||
cf7f64e541 | |||
72ffc9d732 | |||
cc071b15c2 | |||
29cd5661f4 | |||
3169944223 | |||
5e7d27e32f | |||
8570968948 | |||
27bbe23d0c | |||
036fa2bbeb | |||
c7cd8a918e | |||
8348a87a75 | |||
58837b6c00 | |||
8365869ee8 | |||
5c70185236 | |||
e4a385deb7 | |||
dfde6a74eb | |||
a2223dc531 | |||
f75e6a5e3b | |||
a87d521d32 | |||
94d76d4c20 | |||
1176e3bb80 | |||
52b63efe1a | |||
9652c08a4f | |||
ddb437cd60 | |||
1719036d18 | |||
6451495297 | |||
d0b6f19a80 | |||
a77eb889f1 | |||
b35ffa2921 | |||
d0d031600c | |||
a8d3002f31 | |||
a35aa6ea3b | |||
b2d4369b57 | |||
fa3b28d050 | |||
746dd61f48 | |||
29165bdb09 | |||
4b9de44297 | |||
9e48be158b | |||
e64ea5e99a | |||
0fce662954 | |||
b9b9d5f99f | |||
1583525b48 | |||
4d94c85e81 | |||
d97dea258e | |||
da898c37cc | |||
7e05aa825d |
17
README.md
@ -1,6 +1,6 @@
|
||||
# Hacki
|
||||
# Hacki for Hacker News
|
||||
|
||||
A simple Hacker News reader made with Flutter.
|
||||
A simple noiseless Hacker News reader made with Flutter that is just enough.
|
||||
|
||||

|
||||
[](https://apps.apple.com/us/app/hacki/id1602043763)
|
||||
@ -11,6 +11,8 @@ A simple Hacker News reader made with Flutter.
|
||||
|
||||
<noscript><a href="https://liberapay.com/jfeng_for_open_source/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
|
||||
|
||||
[<img src="images/app_store_badge.png" height="50">](https://apps.apple.com/us/app/hacki/id1602043763) [<img src="images/google_play_badge.png" height="50">](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US)
|
||||
|
||||
|
||||
Features:
|
||||
- Log in using your Hacker News account.
|
||||
@ -20,10 +22,11 @@ Features:
|
||||
- Mark stories as favorite.
|
||||
- Browse comments and stories you have posted in the past.
|
||||
- Search for stories on Hacker News.
|
||||
- Double tap to collapse a comment.
|
||||
- Long press to vote on a comment or story.
|
||||
- Swipe to right to pin a story to top.
|
||||
- Collapse comments.
|
||||
- Vote on comments or stories.
|
||||
- Pin stories to the top of home page.
|
||||
- Get in-app notification when there is new reply to your stories or comments.
|
||||
- Download stories for offline reading.
|
||||
- And more...
|
||||
|
||||
|
||||
@ -32,14 +35,14 @@ Features:
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/148859627-48290a22-9679-442b-bae4-97f21546b3ae.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/148859630-93f7e372-f2e7-4357-86c0-250a3f69c10f.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/148859632-b52a89ca-b8d7-464c-a508-faa86bcc87f8.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/148904175-8313d30a-ef84-4f3a-9ac2-f9e06021615d.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/155449312-4208a961-44ac-42b3-968b-9526d4a07787.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/150713047-2710add8-0493-4c42-a710-f96dc77cfde1.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/150918515-0fc4869f-efa3-473f-90af-381daf5e4915.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/152305175-94fa3696-f40f-4f40-b040-f17fc59ff260.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/152301588-070ded9a-117a-48d8-bad4-9f77d54d98df.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/152301590-5383200c-db73-487d-8742-57ccbbbf04e8.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/153973720-8a6aad44-7df3-4deb-8465-8c88b5e5f587.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/153973715-a33018d2-d3b1-4bfa-be39-56f5e3c4830b.png">
|
||||
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/157003000-15671cf0-9470-4a89-b123-f63ca70a970f.png">
|
||||
</p>
|
||||
|
||||
|
||||
|
1
fastlane/metadata/android/en-US/changelogs/22.txt
Normal file
@ -0,0 +1 @@
|
||||
- Bugfixes.
|
1
fastlane/metadata/android/en-US/changelogs/23.txt
Normal file
@ -0,0 +1 @@
|
||||
- Updates to UI.
|
1
fastlane/metadata/android/en-US/changelogs/24.txt
Normal file
@ -0,0 +1 @@
|
||||
- Updates to UI.
|
1
fastlane/metadata/android/en-US/changelogs/25.txt
Normal file
@ -0,0 +1 @@
|
||||
- Updates to UI.
|
1
fastlane/metadata/android/en-US/changelogs/26.txt
Normal file
@ -0,0 +1 @@
|
||||
- Tapping on comments in notification and history screen will lead you directly to the comment.
|
3
fastlane/metadata/android/en-US/changelogs/27.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Tapping on comment in notification or history screen will now lead you directly to the comment.
|
||||
- Fixed the bug where reply box cannot be expanded in editing mode.
|
||||
- Fixed inconsistent font size in history screen.
|
1
fastlane/metadata/android/en-US/changelogs/28.txt
Normal file
@ -0,0 +1 @@
|
||||
- Added offline mode.
|
2
fastlane/metadata/android/en-US/changelogs/29.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Added offline mode.
|
||||
- Bugfixes.
|
2
fastlane/metadata/android/en-US/changelogs/30.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Added offline mode.
|
||||
- Bugfixes.
|
2
fastlane/metadata/android/en-US/changelogs/31.txt
Normal file
@ -0,0 +1,2 @@
|
||||
- Added offline mode.
|
||||
- Bugfixes.
|
14
fastlane/metadata/android/en-US/full_description.txt
Normal file
@ -0,0 +1,14 @@
|
||||
Features:
|
||||
- Log in using your Hacker News account.
|
||||
- Browse stories from various categories.
|
||||
- Submit links.
|
||||
- Leave comments on stories.
|
||||
- Mark stories as favorite.
|
||||
- Browse comments and stories you have posted in the past.
|
||||
- Search for stories on Hacker News.
|
||||
- Collapse comments.
|
||||
- Vote on comments or stories.
|
||||
- Pin stories to the top of home page.
|
||||
- Get in-app notification when there is new reply to your stories or comments.
|
||||
- Download stories for offline reading.
|
||||
- And more...
|
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
After Width: | Height: | Size: 393 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/10.png
Normal file
After Width: | Height: | Size: 420 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
After Width: | Height: | Size: 696 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
After Width: | Height: | Size: 250 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
Normal file
After Width: | Height: | Size: 231 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
Normal file
After Width: | Height: | Size: 903 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
Normal file
After Width: | Height: | Size: 931 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 298 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/8.png
Normal file
After Width: | Height: | Size: 247 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/9.png
Normal file
After Width: | Height: | Size: 496 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
@ -0,0 +1 @@
|
||||
Hacki is a simple noiseless Hacker News reader.
|
1
fastlane/metadata/android/en-US/title.txt
Normal file
@ -0,0 +1 @@
|
||||
Hacki for Hacker News
|
BIN
images/app_store_badge.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
images/google_play_badge.png
Normal file
After Width: | Height: | Size: 11 KiB |
@ -356,7 +356,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -365,7 +365,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.5;
|
||||
MARKETING_VERSION = 0.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -491,7 +491,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -500,7 +500,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.5;
|
||||
MARKETING_VERSION = 0.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -520,7 +520,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -529,7 +529,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.5;
|
||||
MARKETING_VERSION = 0.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
@ -40,7 +40,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
await _authRepository.loggedIn.then((loggedIn) async {
|
||||
if (loggedIn) {
|
||||
final username = await _authRepository.username;
|
||||
final user = await _storiesRepository.fetchUserById(userId: username!);
|
||||
final user = await _storiesRepository.fetchUserBy(userId: username!);
|
||||
|
||||
emit(state.copyWith(
|
||||
isLoggedIn: true,
|
||||
@ -73,8 +73,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
username: event.username, password: event.password);
|
||||
|
||||
if (successful) {
|
||||
final user =
|
||||
await _storiesRepository.fetchUserById(userId: event.username);
|
||||
final user = await _storiesRepository.fetchUserBy(userId: event.username);
|
||||
emit(state.copyWith(
|
||||
user: user,
|
||||
isLoggedIn: true,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
@ -10,8 +12,10 @@ part 'stories_state.dart';
|
||||
|
||||
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoriesBloc({
|
||||
CacheRepository? cacheRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
}) : _storiesRepository =
|
||||
}) : _cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
super(const StoriesState.init()) {
|
||||
on<StoriesInitialize>(onInitialize);
|
||||
@ -19,43 +23,80 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
on<StoriesLoadMore>(onLoadMore);
|
||||
on<StoryLoaded>(onStoryLoaded);
|
||||
on<StoriesLoaded>(onStoriesLoaded);
|
||||
on<StoriesDownload>(onDownload);
|
||||
on<StoriesExitOffline>(onExitOffline);
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
|
||||
final CacheRepository _cacheRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
static const _pageSize = 20;
|
||||
|
||||
Future<void> loadTopStories(
|
||||
Future<void> loadStories(
|
||||
{required StoryType of, required Emitter<StoriesState> emit}) async {
|
||||
final ids = await _storiesRepository.fetchStoryIds(of: of);
|
||||
emit(state
|
||||
.copyWithStoryIdsUpdated(of: of, to: ids)
|
||||
.copyWithCurrentPageUpdated(of: of, to: 0));
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(ids: ids.sublist(0, 20))
|
||||
.listen((story) {
|
||||
add(StoryLoaded(story: story, type: of));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: of));
|
||||
});
|
||||
if (state.offlineReading) {
|
||||
final ids = await _cacheRepository.getCachedStoryIds(of: of);
|
||||
emit(state
|
||||
.copyWithStoryIdsUpdated(of: of, to: ids)
|
||||
.copyWithCurrentPageUpdated(of: of, to: 0));
|
||||
_cacheRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: ids.sublist(0, min(ids.length, _pageSize)))
|
||||
.listen((story) {
|
||||
add(StoryLoaded(story: story, type: of));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: of));
|
||||
});
|
||||
} else {
|
||||
final ids = await _storiesRepository.fetchStoryIds(of: of);
|
||||
emit(state
|
||||
.copyWithStoryIdsUpdated(of: of, to: ids)
|
||||
.copyWithCurrentPageUpdated(of: of, to: 0));
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(ids: ids.sublist(0, _pageSize))
|
||||
.listen((story) {
|
||||
add(StoryLoaded(story: story, type: of));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: of));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onInitialize(
|
||||
StoriesInitialize event, Emitter<StoriesState> emit) async {
|
||||
await loadTopStories(of: StoryType.top, emit: emit);
|
||||
await loadTopStories(of: StoryType.latest, emit: emit);
|
||||
await loadTopStories(of: StoryType.ask, emit: emit);
|
||||
await loadTopStories(of: StoryType.show, emit: emit);
|
||||
await loadTopStories(of: StoryType.jobs, emit: emit);
|
||||
final hasCachedStories = await _cacheRepository.hasCachedStories;
|
||||
emit(state.copyWith(offlineReading: hasCachedStories));
|
||||
await loadStories(of: StoryType.top, emit: emit);
|
||||
await loadStories(of: StoryType.latest, emit: emit);
|
||||
await loadStories(of: StoryType.ask, emit: emit);
|
||||
await loadStories(of: StoryType.show, emit: emit);
|
||||
await loadStories(of: StoryType.jobs, emit: emit);
|
||||
}
|
||||
|
||||
Future<void> onRefresh(
|
||||
StoriesRefresh event, Emitter<StoriesState> emit) async {
|
||||
emit(state.copyWithRefreshed(of: event.type));
|
||||
await loadTopStories(of: event.type, emit: emit);
|
||||
emit(state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
to: StoriesStatus.loading,
|
||||
));
|
||||
|
||||
if (state.offlineReading) {
|
||||
emit(state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
to: StoriesStatus.loaded,
|
||||
));
|
||||
} else {
|
||||
emit(state.copyWithRefreshed(of: event.type));
|
||||
await loadStories(of: event.type, emit: emit);
|
||||
}
|
||||
}
|
||||
|
||||
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
|
||||
emit(state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
to: StoriesStatus.loading,
|
||||
));
|
||||
|
||||
final currentPage = state.currentPageByType[event.type]!;
|
||||
final len = state.storyIdsByType[event.type]!.length;
|
||||
emit(state.copyWithCurrentPageUpdated(of: event.type, to: currentPage + 1));
|
||||
@ -67,18 +108,37 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
upper = len;
|
||||
}
|
||||
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
upper,
|
||||
))
|
||||
.listen((story) {
|
||||
add(StoryLoaded(
|
||||
story: story,
|
||||
type: event.type,
|
||||
));
|
||||
});
|
||||
if (state.offlineReading) {
|
||||
_cacheRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
upper,
|
||||
))
|
||||
.listen((story) {
|
||||
add(StoryLoaded(
|
||||
story: story,
|
||||
type: event.type,
|
||||
));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: event.type));
|
||||
});
|
||||
} else {
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(
|
||||
ids: state.storyIdsByType[event.type]!.sublist(
|
||||
lower,
|
||||
upper,
|
||||
))
|
||||
.listen((story) {
|
||||
add(StoryLoaded(
|
||||
story: story,
|
||||
type: event.type,
|
||||
));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: event.type));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
emit(state.copyWithStatusUpdated(
|
||||
of: event.type, to: StoriesStatus.loaded));
|
||||
@ -87,17 +147,66 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
|
||||
void onStoryLoaded(StoryLoaded event, Emitter<StoriesState> emit) {
|
||||
emit(state.copyWithStoryAdded(of: event.type, story: event.story));
|
||||
if (state.storiesByType[event.type]!.length % _pageSize == 0) {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
to: StoriesStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
|
||||
emit(state.copyWithStatusUpdated(of: event.type, to: StoriesStatus.loaded));
|
||||
}
|
||||
|
||||
Future<void> onDownload(
|
||||
StoriesDownload event, Emitter<StoriesState> emit) async {
|
||||
emit(state.copyWith(
|
||||
downloadStatus: StoriesDownloadStatus.downloading,
|
||||
));
|
||||
|
||||
await _cacheRepository.deleteAllStoryIds();
|
||||
await _cacheRepository.deleteAllStories();
|
||||
await _cacheRepository.deleteAllComments();
|
||||
|
||||
final topIds = await _storiesRepository.fetchStoryIds(of: StoryType.top);
|
||||
final newIds = await _storiesRepository.fetchStoryIds(of: StoryType.latest);
|
||||
final askIds = await _storiesRepository.fetchStoryIds(of: StoryType.ask);
|
||||
final showIds = await _storiesRepository.fetchStoryIds(of: StoryType.show);
|
||||
final jobIds = await _storiesRepository.fetchStoryIds(of: StoryType.jobs);
|
||||
|
||||
await _cacheRepository.cacheStoryIds(of: StoryType.top, ids: topIds);
|
||||
await _cacheRepository.cacheStoryIds(of: StoryType.latest, ids: newIds);
|
||||
await _cacheRepository.cacheStoryIds(of: StoryType.ask, ids: askIds);
|
||||
await _cacheRepository.cacheStoryIds(of: StoryType.show, ids: showIds);
|
||||
await _cacheRepository.cacheStoryIds(of: StoryType.jobs, ids: jobIds);
|
||||
|
||||
final allIds = [...topIds, ...newIds, ...askIds, ...showIds, ...jobIds];
|
||||
|
||||
try {
|
||||
_storiesRepository.fetchStoriesStream(ids: allIds).listen((story) async {
|
||||
if (story.kids.isNotEmpty) {
|
||||
await _cacheRepository.cacheStory(story: story);
|
||||
_storiesRepository
|
||||
.fetchAllChildrenComments(ids: story.kids)
|
||||
.listen((comment) async {
|
||||
if (comment != null) {
|
||||
await _cacheRepository.cacheComment(comment: comment);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).onDone(() {
|
||||
emit(state.copyWith(
|
||||
downloadStatus: StoriesDownloadStatus.finished,
|
||||
));
|
||||
});
|
||||
} catch (_) {
|
||||
emit(state.copyWith(
|
||||
downloadStatus: StoriesDownloadStatus.failure,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onExitOffline(
|
||||
StoriesExitOffline event, Emitter<StoriesState> emit) async {
|
||||
await _cacheRepository.deleteAllStoryIds();
|
||||
await _cacheRepository.deleteAllStories();
|
||||
await _cacheRepository.deleteAllComments();
|
||||
emit(state.copyWith(offlineReading: false));
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
}
|
||||
|
@ -37,6 +37,16 @@ class StoriesLoadMore extends StoriesEvent {
|
||||
List<Object?> get props => [type];
|
||||
}
|
||||
|
||||
class StoriesDownload extends StoriesEvent {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class StoriesExitOffline extends StoriesEvent {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class StoryLoaded extends StoriesEvent {
|
||||
StoryLoaded({required this.story, required this.type});
|
||||
|
||||
|
@ -1,6 +1,17 @@
|
||||
part of 'stories_bloc.dart';
|
||||
|
||||
enum StoriesStatus { loading, loaded }
|
||||
enum StoriesStatus {
|
||||
initial,
|
||||
loading,
|
||||
loaded,
|
||||
}
|
||||
|
||||
enum StoriesDownloadStatus {
|
||||
initial,
|
||||
downloading,
|
||||
finished,
|
||||
failure,
|
||||
}
|
||||
|
||||
class StoriesState extends Equatable {
|
||||
const StoriesState({
|
||||
@ -8,6 +19,8 @@ class StoriesState extends Equatable {
|
||||
required this.storyIdsByType,
|
||||
required this.statusByType,
|
||||
required this.currentPageByType,
|
||||
required this.offlineReading,
|
||||
required this.downloadStatus,
|
||||
});
|
||||
|
||||
const StoriesState.init({
|
||||
@ -26,11 +39,11 @@ class StoriesState extends Equatable {
|
||||
StoryType.jobs: [],
|
||||
},
|
||||
this.statusByType = const <StoryType, StoriesStatus>{
|
||||
StoryType.top: StoriesStatus.loaded,
|
||||
StoryType.latest: StoriesStatus.loaded,
|
||||
StoryType.ask: StoriesStatus.loaded,
|
||||
StoryType.show: StoriesStatus.loaded,
|
||||
StoryType.jobs: StoriesStatus.loaded,
|
||||
StoryType.top: StoriesStatus.initial,
|
||||
StoryType.latest: StoriesStatus.initial,
|
||||
StoryType.ask: StoriesStatus.initial,
|
||||
StoryType.show: StoriesStatus.initial,
|
||||
StoryType.jobs: StoriesStatus.initial,
|
||||
},
|
||||
this.currentPageByType = const <StoryType, int>{
|
||||
StoryType.top: 0,
|
||||
@ -39,24 +52,31 @@ class StoriesState extends Equatable {
|
||||
StoryType.show: 0,
|
||||
StoryType.jobs: 0,
|
||||
},
|
||||
});
|
||||
}) : offlineReading = false,
|
||||
downloadStatus = StoriesDownloadStatus.initial;
|
||||
|
||||
final Map<StoryType, List<Story>> storiesByType;
|
||||
final Map<StoryType, List<int>> storyIdsByType;
|
||||
final Map<StoryType, StoriesStatus> statusByType;
|
||||
final Map<StoryType, int> currentPageByType;
|
||||
final StoriesDownloadStatus downloadStatus;
|
||||
final bool offlineReading;
|
||||
|
||||
StoriesState copyWith({
|
||||
Map<StoryType, List<Story>>? storiesByType,
|
||||
Map<StoryType, List<int>>? storyIdsByType,
|
||||
Map<StoryType, StoriesStatus>? statusByType,
|
||||
Map<StoryType, int>? currentPageByType,
|
||||
StoriesDownloadStatus? downloadStatus,
|
||||
bool? offlineReading,
|
||||
}) {
|
||||
return StoriesState(
|
||||
storiesByType: storiesByType ?? this.storiesByType,
|
||||
storyIdsByType: storyIdsByType ?? this.storyIdsByType,
|
||||
statusByType: statusByType ?? this.statusByType,
|
||||
currentPageByType: currentPageByType ?? this.currentPageByType,
|
||||
offlineReading: offlineReading ?? this.offlineReading,
|
||||
downloadStatus: downloadStatus ?? this.downloadStatus,
|
||||
);
|
||||
}
|
||||
|
||||
@ -71,6 +91,8 @@ class StoriesState extends Equatable {
|
||||
storyIdsByType: storyIdsByType,
|
||||
statusByType: statusByType,
|
||||
currentPageByType: currentPageByType,
|
||||
offlineReading: offlineReading,
|
||||
downloadStatus: downloadStatus,
|
||||
);
|
||||
}
|
||||
|
||||
@ -85,6 +107,8 @@ class StoriesState extends Equatable {
|
||||
storyIdsByType: newMap,
|
||||
statusByType: statusByType,
|
||||
currentPageByType: currentPageByType,
|
||||
offlineReading: offlineReading,
|
||||
downloadStatus: downloadStatus,
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,6 +123,8 @@ class StoriesState extends Equatable {
|
||||
storyIdsByType: storyIdsByType,
|
||||
statusByType: newMap,
|
||||
currentPageByType: currentPageByType,
|
||||
offlineReading: offlineReading,
|
||||
downloadStatus: downloadStatus,
|
||||
);
|
||||
}
|
||||
|
||||
@ -113,6 +139,8 @@ class StoriesState extends Equatable {
|
||||
storyIdsByType: storyIdsByType,
|
||||
statusByType: statusByType,
|
||||
currentPageByType: newMap,
|
||||
offlineReading: offlineReading,
|
||||
downloadStatus: downloadStatus,
|
||||
);
|
||||
}
|
||||
|
||||
@ -130,6 +158,8 @@ class StoriesState extends Equatable {
|
||||
storyIdsByType: newStoryIdsMap,
|
||||
statusByType: newStatusMap,
|
||||
currentPageByType: newCurrentPageMap,
|
||||
offlineReading: offlineReading,
|
||||
downloadStatus: downloadStatus,
|
||||
);
|
||||
}
|
||||
|
||||
@ -139,5 +169,7 @@ class StoriesState extends Equatable {
|
||||
storyIdsByType,
|
||||
statusByType,
|
||||
currentPageByType,
|
||||
offlineReading,
|
||||
downloadStatus,
|
||||
];
|
||||
}
|
||||
|
@ -3,6 +3,12 @@ class Constants {
|
||||
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45';
|
||||
static const String hackerNewsLogoLink =
|
||||
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
|
||||
static const String portfolioLink = 'https://livinglist.github.io';
|
||||
static const String githubLink = 'https://github.com/Livinglist/Hacki';
|
||||
static const String appStoreLink =
|
||||
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
|
||||
static const String googlePlayLink =
|
||||
'https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US';
|
||||
|
||||
static const String hackerNewsLogoPath = 'images/hacker_news_logo.png';
|
||||
static const String hackiIconPath = 'images/hacki_icon.png';
|
||||
@ -11,4 +17,5 @@ class Constants {
|
||||
static const String featureAddStoryToFavList = 'add_story_to_fav_list';
|
||||
static const String featureOpenStoryInWebView = 'open_story_in_web_view';
|
||||
static const String featureLogIn = 'log_in';
|
||||
static const String featurePinToTop = 'pin_to_top';
|
||||
}
|
||||
|
@ -14,5 +14,6 @@ Future<void> setUpLocator() async {
|
||||
..registerSingleton<AuthRepository>(AuthRepository())
|
||||
..registerSingleton<PostRepository>(PostRepository())
|
||||
..registerSingleton<SembastRepository>(SembastRepository())
|
||||
..registerSingleton<CacheRepository>(CacheRepository())
|
||||
..registerSingleton<CacheService>(CacheService());
|
||||
}
|
||||
|
32
lib/cubits/cache/cache_cubit.dart
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
|
||||
part 'cache_state.dart';
|
||||
|
||||
class CacheCubit extends Cubit<CacheState> {
|
||||
CacheCubit({CacheRepository? cacheRepository})
|
||||
: _cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
|
||||
super(CacheState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final CacheRepository _cacheRepository;
|
||||
|
||||
void init() {
|
||||
_cacheRepository.getAllReadStoriesIds().then((allReadStories) {
|
||||
emit(state.copyWith(ids: allReadStories));
|
||||
});
|
||||
}
|
||||
|
||||
void markStoryAsRead(int id) {
|
||||
emit(state.copyWithStoryMarkedAsRead(id: id));
|
||||
_cacheRepository.cacheReadStoryId(id: id);
|
||||
}
|
||||
|
||||
void deleteAllReadStoryIds() {
|
||||
emit(CacheState.init());
|
||||
_cacheRepository.deleteAllReadStoryIds();
|
||||
}
|
||||
}
|
30
lib/cubits/cache/cache_state.dart
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
part of 'cache_cubit.dart';
|
||||
|
||||
class CacheState extends Equatable {
|
||||
const CacheState({required this.storiesReadStatus});
|
||||
|
||||
CacheState.init() : storiesReadStatus = {};
|
||||
|
||||
final Map<int, bool> storiesReadStatus;
|
||||
|
||||
CacheState copyWith({required List<int> ids}) {
|
||||
return CacheState(
|
||||
storiesReadStatus: {
|
||||
...storiesReadStatus,
|
||||
...Map<int, bool>.fromEntries(
|
||||
ids.map((e) => MapEntry<int, bool>(e, true)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
CacheState copyWithStoryMarkedAsRead({required int id}) {
|
||||
return CacheState(storiesReadStatus: {...storiesReadStatus, id: true});
|
||||
}
|
||||
|
||||
CacheState copyWithStoryMarkedAsUnread({required int id}) {
|
||||
return CacheState(storiesReadStatus: {...storiesReadStatus, id: false});
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [storiesReadStatus];
|
||||
}
|
@ -3,29 +3,46 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/services/cache_service.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
|
||||
part 'comments_state.dart';
|
||||
|
||||
class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
||||
CommentsCubit(
|
||||
{required T item,
|
||||
CacheService? cacheService,
|
||||
StoriesRepository? storiesRepository})
|
||||
: _cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
CommentsCubit({
|
||||
CacheService? cacheService,
|
||||
CacheRepository? cacheRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
required bool offlineReading,
|
||||
required T item,
|
||||
}) : _cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
_cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
super(CommentsState.init()) {
|
||||
init(item);
|
||||
}
|
||||
super(CommentsState.init(offlineReading: offlineReading, item: item));
|
||||
|
||||
final CacheService _cacheService;
|
||||
final CacheRepository _cacheRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
|
||||
Future<void> init(T item) async {
|
||||
if (item is Story) {
|
||||
final story = item;
|
||||
final updatedStory = await _storiesRepository.fetchStoryById(story.id);
|
||||
Future<void> init({
|
||||
bool onlyShowTargetComment = false,
|
||||
Comment? targetComment,
|
||||
}) async {
|
||||
if (onlyShowTargetComment) {
|
||||
emit(state.copyWith(
|
||||
comments: targetComment != null ? [targetComment] : [],
|
||||
onlyShowTargetComment: true,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: CommentsStatus.loading));
|
||||
|
||||
if (state.item is Story) {
|
||||
final story = state.item;
|
||||
final updatedStory = state.offlineReading
|
||||
? story
|
||||
: await _storiesRepository.fetchStoryBy(story.id);
|
||||
|
||||
emit(state.copyWith(item: updatedStory));
|
||||
|
||||
@ -35,21 +52,27 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
||||
emit(state.copyWith(
|
||||
comments: List.from(state.comments)..add(cachedComment)));
|
||||
} else {
|
||||
await _storiesRepository
|
||||
.fetchCommentBy(id: id)
|
||||
.then(_onCommentFetched);
|
||||
if (state.offlineReading) {
|
||||
await _cacheRepository
|
||||
.getCachedComment(id: id)
|
||||
.then(_onCommentFetched);
|
||||
} else {
|
||||
await _storiesRepository
|
||||
.fetchCommentBy(id: id)
|
||||
.then(_onCommentFetched);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
status: CommentsStatus.loaded,
|
||||
));
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(
|
||||
status: CommentsStatus.loaded,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
final comment = item;
|
||||
final comment = state.item as Comment;
|
||||
|
||||
emit(state.copyWith(
|
||||
item: item,
|
||||
collapsed: _cacheService.isCollapsed(item.id),
|
||||
collapsed: _cacheService.isCollapsed(state.item.id),
|
||||
));
|
||||
|
||||
for (final id in comment.kids) {
|
||||
@ -58,23 +81,43 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
||||
emit(state.copyWith(
|
||||
comments: List.from(state.comments)..add(cachedComment)));
|
||||
} else {
|
||||
await _storiesRepository
|
||||
.fetchCommentBy(id: id)
|
||||
.then(_onCommentFetched);
|
||||
if (state.offlineReading) {
|
||||
await _cacheRepository
|
||||
.getCachedComment(id: id)
|
||||
.then(_onCommentFetched);
|
||||
} else {
|
||||
await _storiesRepository
|
||||
.fetchCommentBy(id: id)
|
||||
.then(_onCommentFetched);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
status: CommentsStatus.loaded,
|
||||
));
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(
|
||||
status: CommentsStatus.loaded,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
emit(state.copyWith(status: CommentsStatus.loading, comments: []));
|
||||
final offlineReading = await _cacheRepository.hasCachedStories;
|
||||
|
||||
if (offlineReading) {
|
||||
emit(state.copyWith(
|
||||
status: CommentsStatus.loaded,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
status: CommentsStatus.loading,
|
||||
comments: [],
|
||||
));
|
||||
|
||||
final story = (state.item as Story?)!;
|
||||
final updatedStory = await _storiesRepository.fetchStoryById(story.id);
|
||||
final updatedStory = await _storiesRepository.fetchStoryBy(story.id);
|
||||
|
||||
for (final id in updatedStory.kids) {
|
||||
final cachedComment = _cacheService.getComment(id);
|
||||
@ -82,7 +125,17 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
||||
emit(state.copyWith(
|
||||
comments: List.from(state.comments)..add(cachedComment)));
|
||||
} else {
|
||||
await _storiesRepository.fetchCommentBy(id: id).then(_onCommentFetched);
|
||||
final offlineReading = await _cacheRepository.hasCachedStories;
|
||||
|
||||
if (offlineReading) {
|
||||
await _cacheRepository
|
||||
.getCachedComment(id: id)
|
||||
.then(_onCommentFetched);
|
||||
} else {
|
||||
await _storiesRepository
|
||||
.fetchCommentBy(id: id)
|
||||
.then(_onCommentFetched);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,12 +146,21 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
||||
}
|
||||
|
||||
void collapse() {
|
||||
_cacheService.updateCollapsedComments(state.item!.id);
|
||||
_cacheService.updateCollapsedComments(state.item.id);
|
||||
emit(state.copyWith(collapsed: !state.collapsed));
|
||||
}
|
||||
|
||||
void loadAll(T item) {
|
||||
emit(state.copyWith(
|
||||
onlyShowTargetComment: false,
|
||||
comments: [],
|
||||
item: item,
|
||||
));
|
||||
init();
|
||||
}
|
||||
|
||||
void _onCommentFetched(Comment? comment) {
|
||||
if (comment != null) {
|
||||
if (comment != null && !isClosed) {
|
||||
_cacheService.cacheComment(comment);
|
||||
emit(state.copyWith(comments: List.from(state.comments)..add(comment)));
|
||||
}
|
||||
|
@ -13,30 +13,41 @@ class CommentsState extends Equatable {
|
||||
required this.comments,
|
||||
required this.status,
|
||||
required this.collapsed,
|
||||
required this.onlyShowTargetComment,
|
||||
required this.offlineReading,
|
||||
});
|
||||
|
||||
CommentsState.init()
|
||||
: item = null,
|
||||
comments = [],
|
||||
CommentsState.init({
|
||||
required this.offlineReading,
|
||||
required this.item,
|
||||
}) : comments = [],
|
||||
status = CommentsStatus.init,
|
||||
collapsed = false;
|
||||
collapsed = false,
|
||||
onlyShowTargetComment = false;
|
||||
|
||||
final Item? item;
|
||||
final Item item;
|
||||
final List<Comment> comments;
|
||||
final CommentsStatus status;
|
||||
final bool collapsed;
|
||||
final bool onlyShowTargetComment;
|
||||
final bool offlineReading;
|
||||
|
||||
CommentsState copyWith({
|
||||
Item? item,
|
||||
List<Comment>? comments,
|
||||
CommentsStatus? status,
|
||||
bool? collapsed,
|
||||
bool? onlyShowTargetComment,
|
||||
bool? offlineReading,
|
||||
}) {
|
||||
return CommentsState(
|
||||
item: item ?? this.item,
|
||||
comments: comments ?? this.comments,
|
||||
status: status ?? this.status,
|
||||
collapsed: collapsed ?? this.collapsed,
|
||||
onlyShowTargetComment:
|
||||
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
||||
offlineReading: offlineReading ?? this.offlineReading,
|
||||
);
|
||||
}
|
||||
|
||||
@ -46,5 +57,7 @@ class CommentsState extends Equatable {
|
||||
comments,
|
||||
status,
|
||||
collapsed,
|
||||
onlyShowTargetComment,
|
||||
offlineReading,
|
||||
];
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
export 'blocklist/blocklist_cubit.dart';
|
||||
export 'cache/cache_cubit.dart';
|
||||
export 'comments/comments_cubit.dart';
|
||||
export 'edit/edit_cubit.dart';
|
||||
export 'fav/fav_cubit.dart';
|
||||
|
@ -16,13 +16,20 @@ class EditCubit extends Cubit<EditState> {
|
||||
final CacheService _cacheService;
|
||||
final Debouncer _debouncer;
|
||||
|
||||
void onItemTapped(Item item) {
|
||||
void onReplyTapped(Item item) {
|
||||
emit(EditState(
|
||||
replyingTo: item,
|
||||
text: _cacheService.getDraft(replyingTo: item.id),
|
||||
));
|
||||
}
|
||||
|
||||
void onEditTapped(Item itemToBeEdited) {
|
||||
emit(EditState(
|
||||
itemBeingEdited: itemToBeEdited,
|
||||
text: itemToBeEdited.text,
|
||||
));
|
||||
}
|
||||
|
||||
void onReplyBoxClosed() {
|
||||
emit(const EditState.init());
|
||||
}
|
||||
|
@ -4,20 +4,24 @@ class EditState extends Equatable {
|
||||
const EditState({
|
||||
this.text,
|
||||
this.replyingTo,
|
||||
this.itemBeingEdited,
|
||||
});
|
||||
|
||||
const EditState.init()
|
||||
: text = null,
|
||||
replyingTo = null;
|
||||
replyingTo = null,
|
||||
itemBeingEdited = null;
|
||||
|
||||
final String? text;
|
||||
final Item? replyingTo;
|
||||
final Item? itemBeingEdited;
|
||||
|
||||
bool get showReplyBox => replyingTo != null;
|
||||
bool get showReplyBox => replyingTo != null || itemBeingEdited != null;
|
||||
|
||||
EditState copyWith({String? text}) {
|
||||
return EditState(
|
||||
replyingTo: replyingTo,
|
||||
itemBeingEdited: itemBeingEdited,
|
||||
text: text ?? this.text,
|
||||
);
|
||||
}
|
||||
@ -26,5 +30,6 @@ class EditState extends Equatable {
|
||||
List<Object?> get props => [
|
||||
text,
|
||||
replyingTo,
|
||||
itemBeingEdited,
|
||||
];
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
),
|
||||
);
|
||||
|
||||
final story = await _storiesRepository.fetchStoryById(id);
|
||||
final story = await _storiesRepository.fetchStoryBy(id);
|
||||
|
||||
emit(state.copyWith(
|
||||
favStories: List<Story>.from(state.favStories)..insert(0, story)));
|
||||
|
@ -3,7 +3,7 @@ import 'dart:math';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
|
@ -27,6 +27,17 @@ class PostCubit extends Cubit<PostState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> edit({required String text, required int id}) async {
|
||||
emit(state.copyWith(status: PostStatus.loading));
|
||||
final successful = await _postRepository.edit(id: id, text: text);
|
||||
|
||||
if (successful) {
|
||||
emit(state.copyWith(status: PostStatus.successful));
|
||||
} else {
|
||||
emit(state.copyWith(status: PostStatus.failure));
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
emit(state.copyWith(status: PostStatus.init));
|
||||
}
|
||||
|
@ -22,14 +22,14 @@ class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
.then((value) => emit(state.copyWith(showComplexStoryTile: value)));
|
||||
_storageRepository.shouldShowWebFirst
|
||||
.then((value) => emit(state.copyWith(showWebFirst: value)));
|
||||
_storageRepository.shouldCommentBorder
|
||||
.then((value) => emit(state.copyWith(showCommentBorder: value)));
|
||||
_storageRepository.shouldShowEyeCandy
|
||||
.then((value) => emit(state.copyWith(showEyeCandy: value)));
|
||||
_storageRepository.trueDarkMode
|
||||
.then((value) => emit(state.copyWith(useTrueDark: value)));
|
||||
_storageRepository.readerMode
|
||||
.then((value) => emit(state.copyWith(useReader: value)));
|
||||
_storageRepository.markReadStories
|
||||
.then((value) => emit(state.copyWith(markReadStories: value)));
|
||||
}
|
||||
|
||||
void toggleNotificationMode() {
|
||||
@ -47,11 +47,6 @@ class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
_storageRepository.toggleNavigationMode();
|
||||
}
|
||||
|
||||
void toggleCommentBorderMode() {
|
||||
emit(state.copyWith(showCommentBorder: !state.showCommentBorder));
|
||||
_storageRepository.toggleCommentBorderMode();
|
||||
}
|
||||
|
||||
void toggleEyeCandyMode() {
|
||||
emit(state.copyWith(showEyeCandy: !state.showEyeCandy));
|
||||
_storageRepository.toggleEyeCandyMode();
|
||||
@ -66,4 +61,9 @@ class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
emit(state.copyWith(useReader: !state.useReader));
|
||||
_storageRepository.toggleReaderMode();
|
||||
}
|
||||
|
||||
void toggleMarkReadStoriesMode() {
|
||||
emit(state.copyWith(markReadStories: !state.markReadStories));
|
||||
_storageRepository.toggleMarkReadStoriesMode();
|
||||
}
|
||||
}
|
||||
|
@ -5,46 +5,46 @@ class PreferenceState extends Equatable {
|
||||
required this.showNotification,
|
||||
required this.showComplexStoryTile,
|
||||
required this.showWebFirst,
|
||||
required this.showCommentBorder,
|
||||
required this.showEyeCandy,
|
||||
required this.useTrueDark,
|
||||
required this.useReader,
|
||||
required this.markReadStories,
|
||||
});
|
||||
|
||||
const PreferenceState.init()
|
||||
: showNotification = false,
|
||||
showComplexStoryTile = false,
|
||||
showWebFirst = false,
|
||||
showCommentBorder = false,
|
||||
showEyeCandy = false,
|
||||
useTrueDark = false,
|
||||
useReader = false;
|
||||
useReader = false,
|
||||
markReadStories = false;
|
||||
|
||||
final bool showNotification;
|
||||
final bool showComplexStoryTile;
|
||||
final bool showWebFirst;
|
||||
final bool showCommentBorder;
|
||||
final bool showEyeCandy;
|
||||
final bool useTrueDark;
|
||||
final bool useReader;
|
||||
final bool markReadStories;
|
||||
|
||||
PreferenceState copyWith({
|
||||
bool? showNotification,
|
||||
bool? showComplexStoryTile,
|
||||
bool? showWebFirst,
|
||||
bool? showCommentBorder,
|
||||
bool? showEyeCandy,
|
||||
bool? useTrueDark,
|
||||
bool? useReader,
|
||||
bool? markReadStories,
|
||||
}) {
|
||||
return PreferenceState(
|
||||
showNotification: showNotification ?? this.showNotification,
|
||||
showComplexStoryTile: showComplexStoryTile ?? this.showComplexStoryTile,
|
||||
showWebFirst: showWebFirst ?? this.showWebFirst,
|
||||
showCommentBorder: showCommentBorder ?? this.showCommentBorder,
|
||||
showEyeCandy: showEyeCandy ?? this.showEyeCandy,
|
||||
useTrueDark: useTrueDark ?? this.useTrueDark,
|
||||
useReader: useReader ?? this.useReader,
|
||||
markReadStories: markReadStories ?? this.markReadStories,
|
||||
);
|
||||
}
|
||||
|
||||
@ -53,9 +53,9 @@ class PreferenceState extends Equatable {
|
||||
showNotification,
|
||||
showComplexStoryTile,
|
||||
showWebFirst,
|
||||
showCommentBorder,
|
||||
showEyeCandy,
|
||||
useTrueDark,
|
||||
useReader,
|
||||
markReadStories,
|
||||
];
|
||||
}
|
||||
|
@ -35,11 +35,9 @@ class SubmitCubit extends Cubit<SubmitState> {
|
||||
text: state.text,
|
||||
)
|
||||
.then((successful) {
|
||||
if (successful) {
|
||||
emit(state.copyWith(status: SubmitStatus.submitted));
|
||||
} else {
|
||||
emit(state.copyWith(status: SubmitStatus.failure));
|
||||
}
|
||||
emit(state.copyWith(status: SubmitStatus.submitted));
|
||||
}).onError((error, stackTrace) {
|
||||
emit(state.copyWith(status: SubmitStatus.failure));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,12 @@ extension DateTimeExtension on DateTime {
|
||||
final gap = now.year - year;
|
||||
return '$gap year${gap == 1 ? '' : 's'} ago';
|
||||
} else if (diff.inDays > 30) {
|
||||
final gap = (now.month - month).clamp(1, 12);
|
||||
var gap = now.month - month;
|
||||
if (gap == 0) {
|
||||
gap = 1;
|
||||
} else if (gap < 0) {
|
||||
gap = now.month + (12 - month);
|
||||
}
|
||||
return '$gap month${gap == 1 ? '' : 's'} ago';
|
||||
} else if (diff.inDays >= 1) {
|
||||
if (diff.inHours <= 24) {
|
||||
|
@ -7,10 +7,16 @@ import 'package:hacki/config/custom_router.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
Future main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = tempDir.path;
|
||||
Hive.init(tempPath);
|
||||
|
||||
await setUpLocator();
|
||||
|
||||
final savedThemeMode = await AdaptiveTheme.getThemeMode();
|
||||
@ -77,6 +83,10 @@ class HackiApp extends StatelessWidget {
|
||||
lazy: false,
|
||||
create: (context) => PinCubit(),
|
||||
),
|
||||
BlocProvider<CacheCubit>(
|
||||
lazy: false,
|
||||
create: (context) => CacheCubit(),
|
||||
),
|
||||
],
|
||||
child: AdaptiveTheme(
|
||||
light: ThemeData(
|
||||
|
@ -6,6 +6,7 @@ class Comment extends Item {
|
||||
required int id,
|
||||
required int time,
|
||||
required int parent,
|
||||
required int score,
|
||||
required String by,
|
||||
required String text,
|
||||
required List<int> kids,
|
||||
@ -18,7 +19,7 @@ class Comment extends Item {
|
||||
kids: kids,
|
||||
parent: parent,
|
||||
deleted: deleted,
|
||||
score: 0,
|
||||
score: score,
|
||||
descendants: 0,
|
||||
dead: false,
|
||||
parts: [],
|
||||
@ -37,7 +38,7 @@ class Comment extends Item {
|
||||
kids: (json['kids'] as List?)?.cast<int>() ?? [],
|
||||
parent: json['parent'] as int? ?? 0,
|
||||
deleted: json['deleted'] as bool? ?? false,
|
||||
score: 0,
|
||||
score: json['score'] as int? ?? 0,
|
||||
descendants: 0,
|
||||
dead: json['dead'] as bool? ?? false,
|
||||
parts: [],
|
||||
@ -59,6 +60,7 @@ class Comment extends Item {
|
||||
'parent': parent,
|
||||
'deleted': deleted,
|
||||
'dead': dead,
|
||||
'score': score,
|
||||
};
|
||||
|
||||
@override
|
||||
|
@ -146,6 +146,27 @@ class SubmitPostData with PostDataMixin {
|
||||
}
|
||||
}
|
||||
|
||||
class EditPostData with PostDataMixin {
|
||||
EditPostData({
|
||||
required this.hmac,
|
||||
required this.id,
|
||||
this.text,
|
||||
});
|
||||
|
||||
final String hmac;
|
||||
final int id;
|
||||
final String? text;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'hmac': hmac,
|
||||
'id': id,
|
||||
'text': text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FormPostData with PostDataMixin {
|
||||
FormPostData({
|
||||
required this.acct,
|
||||
|
@ -79,6 +79,23 @@ class Story extends Item {
|
||||
String get postedDate =>
|
||||
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'descendants': descendants,
|
||||
'id': id,
|
||||
'score': score,
|
||||
'time': time,
|
||||
'by': by,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'kids': kids,
|
||||
'text': text,
|
||||
'dead': dead,
|
||||
'deleted': deleted,
|
||||
'type': type,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
|
124
lib/repositories/cache_repository.dart
Normal file
@ -0,0 +1,124 @@
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
class CacheRepository {
|
||||
CacheRepository({
|
||||
Future<Box<bool>>? readStoryIdBox,
|
||||
Future<Box<List<int>>>? storyIdBox,
|
||||
Future<Box<Map<dynamic, dynamic>>>? storyBox,
|
||||
Future<LazyBox<Map<dynamic, dynamic>>>? commentBox,
|
||||
}) : _readStoryIdBox =
|
||||
readStoryIdBox ?? Hive.openBox<bool>(_readStoryIdBoxName),
|
||||
_storyIdBox = storyIdBox ?? Hive.openBox<List<int>>(_storyIdBoxName),
|
||||
_storyBox =
|
||||
storyBox ?? Hive.openBox<Map<dynamic, dynamic>>(_storyBoxName),
|
||||
_commentBox = commentBox ??
|
||||
Hive.openLazyBox<Map<dynamic, dynamic>>(_commentBoxName);
|
||||
|
||||
static const _readStoryIdBoxName = 'readStoryIdBox';
|
||||
static const _storyIdBoxName = 'storyIdBox';
|
||||
static const _storyBoxName = 'storyBox';
|
||||
static const _commentBoxName = 'commentBox';
|
||||
final Future<Box<bool>> _readStoryIdBox;
|
||||
final Future<Box<List<int>>> _storyIdBox;
|
||||
final Future<Box<Map<dynamic, dynamic>>> _storyBox;
|
||||
final Future<LazyBox<Map<dynamic, dynamic>>> _commentBox;
|
||||
|
||||
Future<bool> get hasCachedStories => _storyBox.then((box) => box.isNotEmpty);
|
||||
|
||||
Future<bool> wasRead({required int id}) async {
|
||||
final box = await _readStoryIdBox;
|
||||
final val = box.get(id.toString());
|
||||
return val != null;
|
||||
}
|
||||
|
||||
Future<void> cacheReadStoryId({required int id}) async {
|
||||
final box = await _readStoryIdBox;
|
||||
return box.put(id.toString(), true);
|
||||
}
|
||||
|
||||
Future<List<int>> getAllReadStoriesIds() async {
|
||||
final box = await _readStoryIdBox;
|
||||
final allReads = box.keys.cast<String>().map(int.parse).toList();
|
||||
return allReads;
|
||||
}
|
||||
|
||||
Future<void> cacheStoryIds(
|
||||
{required StoryType of, required List<int> ids}) async {
|
||||
final box = await _storyIdBox;
|
||||
return box.put(of.name, ids);
|
||||
}
|
||||
|
||||
Future<void> cacheStory({required Story story}) async {
|
||||
final box = await _storyBox;
|
||||
return box.put(story.id.toString(), story.toJson());
|
||||
}
|
||||
|
||||
Future<List<int>> getCachedStoryIds({required StoryType of}) async {
|
||||
final box = await _storyIdBox;
|
||||
final ids = box.get(of.name);
|
||||
return ids ?? [];
|
||||
}
|
||||
|
||||
Stream<Story> getCachedStoriesStream({required List<int> ids}) async* {
|
||||
final box = await _storyBox;
|
||||
|
||||
for (final id in ids) {
|
||||
final json = box.get(id.toString());
|
||||
|
||||
if (json == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final story = Story.fromJson(json.cast<String, dynamic>());
|
||||
yield story;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Future<Story?> getCachedStory({required int id}) async {
|
||||
final box = await _storyBox;
|
||||
final json = box.get(id.toString());
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
final story = Story.fromJson(json.cast<String, dynamic>());
|
||||
return story;
|
||||
}
|
||||
|
||||
Future<void> cacheComment({required Comment comment}) async {
|
||||
final box = await _commentBox;
|
||||
return box.put(comment.id.toString(), comment.toJson());
|
||||
}
|
||||
|
||||
Future<Comment?> getCachedComment({required int id}) async {
|
||||
final box = await _commentBox;
|
||||
final json = await box.get(id.toString());
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
final comment = Comment.fromJson(json.cast<String, dynamic>());
|
||||
return comment;
|
||||
}
|
||||
|
||||
Future<int> deleteAllReadStoryIds() async {
|
||||
final box = await _readStoryIdBox;
|
||||
return box.clear();
|
||||
}
|
||||
|
||||
Future<int> deleteAllStoryIds() async {
|
||||
final box = await _storyIdBox;
|
||||
return box.clear();
|
||||
}
|
||||
|
||||
Future<int> deleteAllStories() async {
|
||||
final box = await _storyBox;
|
||||
return box.clear();
|
||||
}
|
||||
|
||||
Future<int> deleteAllComments() async {
|
||||
final box = await _commentBox;
|
||||
return box.clear();
|
||||
}
|
||||
}
|
@ -84,6 +84,45 @@ class PostRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> edit({
|
||||
required int id,
|
||||
String? text,
|
||||
}) async {
|
||||
final username = await _storageRepository.username;
|
||||
final password = await _storageRepository.password;
|
||||
|
||||
if (username == null || password == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final formResponse = await _getFormResponse(
|
||||
username: username,
|
||||
password: password,
|
||||
id: id,
|
||||
path: 'edit',
|
||||
);
|
||||
final formValues = HtmlUtil.getHiddenFormValues(formResponse.data);
|
||||
|
||||
if (formValues == null || formValues.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final cookie = formResponse.headers.value(HttpHeaders.setCookieHeader);
|
||||
|
||||
final uri = Uri.https(authority, 'xedit');
|
||||
final PostDataMixin data = EditPostData(
|
||||
hmac: formValues['hmac']!,
|
||||
id: id,
|
||||
text: text,
|
||||
);
|
||||
|
||||
return _performDefaultPost(
|
||||
uri,
|
||||
data,
|
||||
cookie: cookie,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response<List<int>>> _getFormResponse({
|
||||
required String username,
|
||||
required String password,
|
||||
|
@ -1,4 +1,5 @@
|
||||
export 'auth_repository.dart';
|
||||
export 'cache_repository.dart';
|
||||
export 'post_repository.dart';
|
||||
export 'search_repository.dart';
|
||||
export 'sembast_repository.dart';
|
||||
|
@ -12,6 +12,8 @@ class StorageRepository {
|
||||
static const String _passwordKey = 'password';
|
||||
static const String _blocklistKey = 'blocklist';
|
||||
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
|
||||
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
|
||||
|
||||
static const String _notificationModeKey = 'notificationMode';
|
||||
static const String _trueDarkModeKey = 'trueDarkMode';
|
||||
static const String _readerModeKey = 'readerMode';
|
||||
@ -23,18 +25,16 @@ class StorageRepository {
|
||||
/// The key of a boolean value deciding whether or not user should be
|
||||
/// navigated to web view first. Defaults to false.
|
||||
static const String _navigationModeKey = 'navigationMode';
|
||||
|
||||
static const String _commentBorderModeKey = 'commentBorderMode';
|
||||
static const String _eyeCandyModeKey = 'eyeCandyMode';
|
||||
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
|
||||
static const String _markReadStoriesModeKey = 'markReadStoriesMode';
|
||||
|
||||
static const bool _notificationModeDefaultValue = true;
|
||||
static const bool _displayModeDefaultValue = true;
|
||||
static const bool _navigationModeDefaultValue = true;
|
||||
static const bool _commentBorderModeDefaultValue = true;
|
||||
static const bool _eyeCandyModeDefaultValue = false;
|
||||
static const bool _trueDarkModeDefaultValue = false;
|
||||
static const bool _readerModeKeyDefaultValue = true;
|
||||
static const bool _readerModeDefaultValue = true;
|
||||
static const bool _markReadStoriesModeDefaultValue = true;
|
||||
|
||||
final Future<SharedPreferences> _prefs;
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
@ -60,9 +60,6 @@ class StorageRepository {
|
||||
Future<bool> get shouldShowWebFirst async => _prefs.then((prefs) =>
|
||||
prefs.getBool(_navigationModeKey) ?? _navigationModeDefaultValue);
|
||||
|
||||
Future<bool> get shouldCommentBorder async => _prefs.then((prefs) =>
|
||||
prefs.getBool(_commentBorderModeKey) ?? _commentBorderModeDefaultValue);
|
||||
|
||||
Future<bool> get shouldShowEyeCandy async => _prefs.then(
|
||||
(prefs) => prefs.getBool(_eyeCandyModeKey) ?? _eyeCandyModeDefaultValue);
|
||||
|
||||
@ -70,7 +67,11 @@ class StorageRepository {
|
||||
(prefs) => prefs.getBool(_trueDarkModeKey) ?? _trueDarkModeDefaultValue);
|
||||
|
||||
Future<bool> get readerMode async => _prefs.then(
|
||||
(prefs) => prefs.getBool(_readerModeKey) ?? _readerModeKeyDefaultValue);
|
||||
(prefs) => prefs.getBool(_readerModeKey) ?? _readerModeDefaultValue);
|
||||
|
||||
Future<bool> get markReadStories async => _prefs.then((prefs) =>
|
||||
prefs.getBool(_markReadStoriesModeKey) ??
|
||||
_markReadStoriesModeDefaultValue);
|
||||
|
||||
Future<List<int>> get unreadCommentsIds async => _prefs.then((prefs) =>
|
||||
prefs.getStringList(_unreadCommentsIdsKey)?.map(int.parse).toList() ??
|
||||
@ -124,13 +125,6 @@ class StorageRepository {
|
||||
await prefs.setBool(_navigationModeKey, !currentMode);
|
||||
}
|
||||
|
||||
Future<void> toggleCommentBorderMode() async {
|
||||
final prefs = await _prefs;
|
||||
final currentMode =
|
||||
prefs.getBool(_commentBorderModeKey) ?? _commentBorderModeDefaultValue;
|
||||
await prefs.setBool(_commentBorderModeKey, !currentMode);
|
||||
}
|
||||
|
||||
Future<void> toggleEyeCandyMode() async {
|
||||
final prefs = await _prefs;
|
||||
final currentMode =
|
||||
@ -148,10 +142,17 @@ class StorageRepository {
|
||||
Future<void> toggleReaderMode() async {
|
||||
final prefs = await _prefs;
|
||||
final currentMode =
|
||||
prefs.getBool(_readerModeKey) ?? _readerModeKeyDefaultValue;
|
||||
prefs.getBool(_readerModeKey) ?? _readerModeDefaultValue;
|
||||
await prefs.setBool(_readerModeKey, !currentMode);
|
||||
}
|
||||
|
||||
Future<void> toggleMarkReadStoriesMode() async {
|
||||
final prefs = await _prefs;
|
||||
final currentMode = prefs.getBool(_markReadStoriesModeKey) ??
|
||||
_markReadStoriesModeDefaultValue;
|
||||
await prefs.setBool(_markReadStoriesModeKey, !currentMode);
|
||||
}
|
||||
|
||||
Future<void> addFav({required String username, required int id}) async {
|
||||
final prefs = await _prefs;
|
||||
final key = _getFavKey(username);
|
||||
|
@ -1,7 +1,8 @@
|
||||
import 'package:firebase/firebase_io.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:html_unescape/html_unescape.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class StoriesRepository {
|
||||
StoriesRepository({
|
||||
@ -11,7 +12,7 @@ class StoriesRepository {
|
||||
final FirebaseClient _firebaseClient;
|
||||
static const _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
|
||||
|
||||
Future<User> fetchUserById({required String userId}) async {
|
||||
Future<User> fetchUserBy({required String userId}) async {
|
||||
final user = await _firebaseClient
|
||||
.get('${_baseUrl}user/$userId.json')
|
||||
.then((dynamic val) {
|
||||
@ -47,7 +48,7 @@ class StoriesRepository {
|
||||
return ids;
|
||||
}
|
||||
|
||||
Future<Story> fetchStoryById(int id) async {
|
||||
Future<Story> fetchStoryBy(int id) async {
|
||||
final story = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic val) {
|
||||
@ -128,7 +129,7 @@ class StoriesRepository {
|
||||
Future<Item?> fetchItemBy({required int id}) async {
|
||||
final item = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic val) {
|
||||
.then((dynamic val) async {
|
||||
if (val == null) {
|
||||
return null;
|
||||
}
|
||||
@ -138,6 +139,9 @@ class StoriesRepository {
|
||||
final story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (json['type'] == 'comment') {
|
||||
final text = json['text'] as String? ?? '';
|
||||
final parsedText = await compute<String, String>(_parseHtml, text);
|
||||
json['text'] = parsedText;
|
||||
final comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
@ -172,6 +176,32 @@ class StoriesRepository {
|
||||
return item as Story;
|
||||
}
|
||||
|
||||
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments(
|
||||
{required int id}) async {
|
||||
Item? item;
|
||||
final parentComments = <Comment>[];
|
||||
|
||||
do {
|
||||
item = await fetchItemBy(id: item?.parent ?? id);
|
||||
if (item is Comment) {
|
||||
parentComments.add(item);
|
||||
}
|
||||
if (item == null) return null;
|
||||
} while (item is Comment);
|
||||
|
||||
return Tuple2<Story, List<Comment>>(item as Story, parentComments);
|
||||
}
|
||||
|
||||
Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* {
|
||||
for (final id in ids) {
|
||||
final comment = await fetchCommentBy(id: id);
|
||||
if (comment != null) {
|
||||
yield comment;
|
||||
yield* fetchAllChildrenComments(ids: comment.kids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String _parseHtml(String text) {
|
||||
return HtmlUnescape()
|
||||
.convert(text)
|
||||
|
@ -1,3 +1,5 @@
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -21,7 +23,7 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const String routeName = '/home';
|
||||
static const String routeName = '/';
|
||||
|
||||
static Route route() {
|
||||
return MaterialPageRoute<HomeScreen>(
|
||||
@ -54,6 +56,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
// Constants.featureLogIn,
|
||||
// Constants.featureAddStoryToFavList,
|
||||
// Constants.featureOpenStoryInWebView,
|
||||
// Constants.featurePinToTop,
|
||||
// ]);
|
||||
|
||||
SchedulerBinding.instance?.addPostFrameCallback((_) {
|
||||
@ -76,6 +79,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.showComplexStoryTile != current.showComplexStoryTile,
|
||||
builder: (context, preferenceState) {
|
||||
final pinnedStories = BlocBuilder<PinCubit, PinState>(
|
||||
builder: (context, state) {
|
||||
@ -88,8 +93,10 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
motion: const BehindMotion(),
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (_) =>
|
||||
context.read<PinCubit>().unpinStory(story),
|
||||
onPressed: (_) {
|
||||
HapticFeedback.lightImpact();
|
||||
context.read<PinCubit>().unpinStory(story);
|
||||
},
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
icon: preferenceState.showComplexStoryTile
|
||||
@ -122,276 +129,175 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
},
|
||||
);
|
||||
|
||||
return BlocConsumer<StoriesBloc, StoriesState>(
|
||||
listener: (context, state) {
|
||||
if (state.statusByType[StoryType.top] == StoriesStatus.loaded) {
|
||||
refreshControllerTop
|
||||
..refreshCompleted(resetFooterState: true)
|
||||
..loadComplete();
|
||||
}
|
||||
if (state.statusByType[StoryType.latest] == StoriesStatus.loaded) {
|
||||
refreshControllerNew
|
||||
..refreshCompleted(resetFooterState: true)
|
||||
..loadComplete();
|
||||
}
|
||||
if (state.statusByType[StoryType.ask] == StoriesStatus.loaded) {
|
||||
refreshControllerAsk
|
||||
..refreshCompleted(resetFooterState: true)
|
||||
..loadComplete();
|
||||
}
|
||||
if (state.statusByType[StoryType.show] == StoriesStatus.loaded) {
|
||||
refreshControllerShow
|
||||
..refreshCompleted(resetFooterState: true)
|
||||
..loadComplete();
|
||||
}
|
||||
if (state.statusByType[StoryType.jobs] == StoriesStatus.loaded) {
|
||||
refreshControllerJobs
|
||||
..refreshCompleted(resetFooterState: true)
|
||||
..loadComplete();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return WillPopScope(
|
||||
onWillPop: () => Future.value(false),
|
||||
child: DefaultTabController(
|
||||
length: 6,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size(0, 48),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.top,
|
||||
),
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
controller: tabController,
|
||||
indicatorColor: Colors.orange,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Text(
|
||||
'TOP',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: currentIndex == 0
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
return BlocBuilder<CacheCubit, CacheState>(
|
||||
builder: (context, cacheState) {
|
||||
return DefaultTabController(
|
||||
length: 6,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size(0, 48),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.top,
|
||||
),
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
controller: tabController,
|
||||
indicatorColor: Colors.orange,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Text(
|
||||
'TOP',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: currentIndex == 0
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'NEW',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: currentIndex == 1
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'NEW',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: currentIndex == 1
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'ASK',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: currentIndex == 2
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'ASK',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: currentIndex == 2
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'SHOW',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: currentIndex == 3
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'SHOW',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: currentIndex == 3
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'JOBS',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: currentIndex == 4
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'JOBS',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: currentIndex == 4
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: DescribedFeatureOverlay(
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: const Icon(
|
||||
Icons.person,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
featureId: Constants.featureLogIn,
|
||||
title: const Text(''),
|
||||
description: const Text(
|
||||
'Log in using your Hacker News account '
|
||||
'to check out stories and comments you have '
|
||||
'posted in the past, and get in-app '
|
||||
'notification when there is new reply to '
|
||||
'your comments or stories.\n\nAlso, you can '
|
||||
'long press here to submit a new link to '
|
||||
'Hacker News.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
child: BlocBuilder<NotificationCubit,
|
||||
NotificationState>(
|
||||
builder: (context, state) {
|
||||
if (state.unreadCommentsIds.isEmpty) {
|
||||
return Icon(
|
||||
),
|
||||
Tab(
|
||||
child: DescribedFeatureOverlay(
|
||||
barrierDismissible: false,
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: const Icon(
|
||||
Icons.person,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
featureId: Constants.featureLogIn,
|
||||
title: const Text(''),
|
||||
description: const Text(
|
||||
'Log in using your Hacker News account '
|
||||
'to check out stories and comments you have '
|
||||
'posted in the past, and get in-app '
|
||||
'notification when there is new reply to '
|
||||
'your comments or stories.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
child: BlocBuilder<NotificationCubit,
|
||||
NotificationState>(
|
||||
builder: (context, state) {
|
||||
if (state.unreadCommentsIds.isEmpty) {
|
||||
return Icon(
|
||||
Icons.person,
|
||||
size: 16,
|
||||
color: currentIndex == 5
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
);
|
||||
} else {
|
||||
return Badge(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
badgeContent: Container(
|
||||
height: 3,
|
||||
width: 3,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 16,
|
||||
color: currentIndex == 5
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
);
|
||||
} else {
|
||||
return Badge(
|
||||
borderRadius:
|
||||
BorderRadius.circular(100),
|
||||
badgeContent: Container(
|
||||
height: 3,
|
||||
width: 3,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 16,
|
||||
color: currentIndex == 5
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: tabController,
|
||||
children: [
|
||||
ItemsListView<Story>(
|
||||
pinnable: true,
|
||||
showWebPreview: preferenceState.showComplexStoryTile,
|
||||
refreshController: refreshControllerTop,
|
||||
items: state.storiesByType[StoryType.top]!,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesRefresh(type: StoryType.top));
|
||||
},
|
||||
onLoadMore: () {
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesLoadMore(type: StoryType.top));
|
||||
},
|
||||
onTap: onStoryTapped,
|
||||
onPinned: context.read<PinCubit>().pinStory,
|
||||
header: pinnedStories,
|
||||
),
|
||||
],
|
||||
),
|
||||
ItemsListView<Story>(
|
||||
pinnable: true,
|
||||
showWebPreview: preferenceState.showComplexStoryTile,
|
||||
refreshController: refreshControllerNew,
|
||||
items: state.storiesByType[StoryType.latest]!,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesRefresh(type: StoryType.latest));
|
||||
},
|
||||
onLoadMore: () {
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesLoadMore(type: StoryType.latest));
|
||||
},
|
||||
onTap: onStoryTapped,
|
||||
onPinned: context.read<PinCubit>().pinStory,
|
||||
header: pinnedStories,
|
||||
),
|
||||
ItemsListView<Story>(
|
||||
pinnable: true,
|
||||
showWebPreview: preferenceState.showComplexStoryTile,
|
||||
refreshController: refreshControllerAsk,
|
||||
items: state.storiesByType[StoryType.ask]!,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesRefresh(type: StoryType.ask));
|
||||
},
|
||||
onLoadMore: () {
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesLoadMore(type: StoryType.ask));
|
||||
},
|
||||
onTap: onStoryTapped,
|
||||
onPinned: context.read<PinCubit>().pinStory,
|
||||
header: pinnedStories,
|
||||
),
|
||||
ItemsListView<Story>(
|
||||
pinnable: true,
|
||||
showWebPreview: preferenceState.showComplexStoryTile,
|
||||
refreshController: refreshControllerShow,
|
||||
items: state.storiesByType[StoryType.show]!,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesRefresh(type: StoryType.show));
|
||||
},
|
||||
onLoadMore: () {
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesLoadMore(type: StoryType.show));
|
||||
},
|
||||
onTap: onStoryTapped,
|
||||
onPinned: context.read<PinCubit>().pinStory,
|
||||
header: pinnedStories,
|
||||
),
|
||||
ItemsListView<Story>(
|
||||
pinnable: true,
|
||||
showWebPreview: preferenceState.showComplexStoryTile,
|
||||
refreshController: refreshControllerJobs,
|
||||
items: state.storiesByType[StoryType.jobs]!,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesRefresh(type: StoryType.jobs));
|
||||
},
|
||||
onLoadMore: () {
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesLoadMore(type: StoryType.jobs));
|
||||
},
|
||||
onTap: onStoryTapped,
|
||||
onPinned: context.read<PinCubit>().pinStory,
|
||||
header: pinnedStories,
|
||||
),
|
||||
const ProfileScreen(),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: tabController,
|
||||
children: [
|
||||
StoriesListView(
|
||||
storyType: StoryType.top,
|
||||
header: pinnedStories,
|
||||
onStoryTapped: onStoryTapped,
|
||||
refreshController: refreshControllerTop,
|
||||
),
|
||||
StoriesListView(
|
||||
storyType: StoryType.latest,
|
||||
header: pinnedStories,
|
||||
onStoryTapped: onStoryTapped,
|
||||
refreshController: refreshControllerNew,
|
||||
),
|
||||
StoriesListView(
|
||||
storyType: StoryType.ask,
|
||||
header: pinnedStories,
|
||||
onStoryTapped: onStoryTapped,
|
||||
refreshController: refreshControllerAsk,
|
||||
),
|
||||
StoriesListView(
|
||||
storyType: StoryType.show,
|
||||
header: pinnedStories,
|
||||
onStoryTapped: onStoryTapped,
|
||||
refreshController: refreshControllerShow,
|
||||
),
|
||||
StoriesListView(
|
||||
storyType: StoryType.jobs,
|
||||
header: pinnedStories,
|
||||
onStoryTapped: onStoryTapped,
|
||||
refreshController: refreshControllerJobs,
|
||||
),
|
||||
const ProfileScreen(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -403,6 +309,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
void onStoryTapped(Story story) {
|
||||
final showWebFirst = context.read<PreferenceCubit>().state.showWebFirst;
|
||||
final useReader = context.read<PreferenceCubit>().state.useReader;
|
||||
final offlineReading = context.read<StoriesBloc>().state.offlineReading;
|
||||
final firstTimeReading = cacheService.isFirstTimeReading(story.id);
|
||||
|
||||
// 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.
|
||||
@ -413,10 +321,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
arguments: StoryScreenArgs(story: story));
|
||||
}
|
||||
|
||||
if (isJobWithLink ||
|
||||
(showWebFirst && cacheService.isFirstTimeReading(story.id))) {
|
||||
if (!offlineReading &&
|
||||
(isJobWithLink || (showWebFirst && firstTimeReading))) {
|
||||
LinkUtil.launchUrl(story.url, useReader: useReader);
|
||||
cacheService.store(story.id);
|
||||
}
|
||||
|
||||
context.read<CacheCubit>().markStoryAsRead(story.id);
|
||||
}
|
||||
}
|
||||
|
@ -16,10 +16,9 @@ import 'package:hacki/screens/profile/widgets/widgets.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
enum PageType {
|
||||
enum _PageType {
|
||||
fav,
|
||||
history,
|
||||
settings,
|
||||
@ -40,8 +39,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
final refreshControllerFav = RefreshController();
|
||||
final refreshControllerNotification = RefreshController();
|
||||
final scrollController = ScrollController();
|
||||
final throttle = Throttle(delay: const Duration(seconds: 2));
|
||||
|
||||
PageType pageType = PageType.notification;
|
||||
_PageType pageType = _PageType.notification;
|
||||
|
||||
final magicWords = <String>[
|
||||
'to be a lord.',
|
||||
@ -52,6 +52,16 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
'to infinity and beyond!',
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
refreshControllerHistory.dispose();
|
||||
refreshControllerFav.dispose();
|
||||
refreshControllerNotification.dispose();
|
||||
scrollController.dispose();
|
||||
throttle.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@ -61,6 +71,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, authState) {
|
||||
return BlocConsumer<NotificationCubit, NotificationState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.status != current.status,
|
||||
listener: (context, notificationState) {
|
||||
if (notificationState.status == NotificationStatus.loaded) {
|
||||
refreshControllerNotification
|
||||
@ -71,30 +83,10 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
builder: (context, notificationState) {
|
||||
return Stack(
|
||||
children: [
|
||||
if (!authState.isLoggedIn && pageType == PageType.history)
|
||||
Positioned.fill(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 120,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: onLoginTapped,
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.deepOrange),
|
||||
child: const Text(
|
||||
'Log in',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
top: 50,
|
||||
child: Offstage(
|
||||
offstage: !authState.isLoggedIn ||
|
||||
pageType != PageType.history,
|
||||
offstage: pageType != _PageType.history,
|
||||
child: BlocConsumer<HistoryCubit, HistoryState>(
|
||||
listener: (context, historyState) {
|
||||
if (historyState.status == HistoryStatus.loaded) {
|
||||
@ -104,8 +96,18 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
}
|
||||
},
|
||||
builder: (context, historyState) {
|
||||
if ((!authState.isLoggedIn ||
|
||||
historyState.submittedItems.isEmpty) &&
|
||||
historyState.status != HistoryStatus.loading) {
|
||||
return const CenteredMessageView(
|
||||
content: 'Your past comments and stories will '
|
||||
'show up here.',
|
||||
);
|
||||
}
|
||||
|
||||
return ItemsListView<Item>(
|
||||
showWebPreview: false,
|
||||
useConsistentFontSize: true,
|
||||
refreshController: refreshControllerHistory,
|
||||
items: historyState.submittedItems
|
||||
.where((e) => !e.dead && !e.deleted)
|
||||
@ -123,17 +125,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
StoryScreen.routeName,
|
||||
arguments: StoryScreenArgs(story: item));
|
||||
} else if (item is Comment) {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchParentStory(id: item.parent)
|
||||
.then((story) {
|
||||
if (story != null && mounted) {
|
||||
HackiApp.navigatorKey.currentState!
|
||||
.pushNamed(StoryScreen.routeName,
|
||||
arguments: StoryScreenArgs(
|
||||
story: story));
|
||||
}
|
||||
});
|
||||
onCommentTapped(item);
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -144,7 +136,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
Positioned.fill(
|
||||
top: 50,
|
||||
child: Offstage(
|
||||
offstage: pageType != PageType.fav,
|
||||
offstage: pageType != _PageType.fav,
|
||||
child: BlocConsumer<FavCubit, FavState>(
|
||||
listener: (context, favState) {
|
||||
if (favState.status == FavStatus.loaded) {
|
||||
@ -154,6 +146,15 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
}
|
||||
},
|
||||
builder: (context, favState) {
|
||||
if (favState.favStories.isEmpty &&
|
||||
favState.status != FavStatus.loading) {
|
||||
return const CenteredMessageView(
|
||||
content:
|
||||
'Your favorite stories will show up here.'
|
||||
'\nThey will be synced to your Hacker '
|
||||
'News account if you are logged in.',
|
||||
);
|
||||
}
|
||||
return ItemsListView<Story>(
|
||||
showWebPreview:
|
||||
preferenceState.showComplexStoryTile,
|
||||
@ -179,35 +180,22 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
Positioned.fill(
|
||||
top: 50,
|
||||
child: Offstage(
|
||||
offstage: pageType != PageType.search,
|
||||
offstage: pageType != _PageType.search,
|
||||
child: const SearchScreen(),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
top: 50,
|
||||
child: Offstage(
|
||||
offstage: pageType != PageType.notification,
|
||||
offstage: pageType != _PageType.notification,
|
||||
child: InboxView(
|
||||
refreshController: refreshControllerNotification,
|
||||
unreadCommentsIds:
|
||||
notificationState.unreadCommentsIds,
|
||||
comments: notificationState.comments,
|
||||
onCommentTapped: (comment) {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchParentStory(id: comment.parent)
|
||||
.then((story) {
|
||||
if (story != null && mounted) {
|
||||
context
|
||||
.read<NotificationCubit>()
|
||||
.markAsRead(comment);
|
||||
HackiApp.navigatorKey.currentState!.pushNamed(
|
||||
StoryScreen.routeName,
|
||||
arguments: StoryScreenArgs(
|
||||
story: story,
|
||||
),
|
||||
);
|
||||
}
|
||||
onCommentTapped: (cmt) {
|
||||
onCommentTapped(cmt, then: () {
|
||||
context.read<NotificationCubit>().markAsRead(cmt);
|
||||
});
|
||||
},
|
||||
onMarkAllAsReadTapped: () {
|
||||
@ -226,7 +214,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
Positioned.fill(
|
||||
top: 50,
|
||||
child: Offstage(
|
||||
offstage: pageType != PageType.settings,
|
||||
offstage: pageType != _PageType.settings,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
@ -247,6 +235,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
}
|
||||
},
|
||||
),
|
||||
const OfflineListTile(),
|
||||
SwitchListTile(
|
||||
title: const Text('Notification on New Reply'),
|
||||
subtitle: const Text(
|
||||
@ -307,14 +296,22 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
activeColor: Colors.orange,
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Show Comment Outlines'),
|
||||
subtitle: const Text('be nice to your eyes.'),
|
||||
value: preferenceState.showCommentBorder,
|
||||
title: const Text('Mark Read Stories'),
|
||||
subtitle: const Text(
|
||||
'grey out stories you have read.'),
|
||||
value: preferenceState.markReadStories,
|
||||
onChanged: (val) {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
if (!val) {
|
||||
context
|
||||
.read<CacheCubit>()
|
||||
.deleteAllReadStoryIds();
|
||||
}
|
||||
|
||||
context
|
||||
.read<PreferenceCubit>()
|
||||
.toggleCommentBorderMode();
|
||||
.toggleMarkReadStoriesMode();
|
||||
},
|
||||
activeColor: Colors.orange,
|
||||
),
|
||||
@ -327,13 +324,6 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
context
|
||||
.read<PreferenceCubit>()
|
||||
.toggleEyeCandyMode();
|
||||
|
||||
final inAppReview = InAppReview.instance;
|
||||
inAppReview.isAvailable().then((available) {
|
||||
if (available) {
|
||||
inAppReview.requestReview();
|
||||
}
|
||||
});
|
||||
},
|
||||
activeColor: Colors.orange,
|
||||
),
|
||||
@ -346,13 +336,6 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
context
|
||||
.read<PreferenceCubit>()
|
||||
.toggleTrueDarkMode();
|
||||
|
||||
final inAppReview = InAppReview.instance;
|
||||
inAppReview.isAvailable().then((available) {
|
||||
if (available) {
|
||||
inAppReview.requestReview();
|
||||
}
|
||||
});
|
||||
},
|
||||
activeColor: Colors.orange,
|
||||
),
|
||||
@ -377,7 +360,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'Hacki',
|
||||
applicationVersion: 'v0.1.5',
|
||||
applicationVersion: 'v0.2.0',
|
||||
applicationIcon: Image.asset(
|
||||
Constants.hackiIconPath,
|
||||
height: 50,
|
||||
@ -386,7 +369,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => LinkUtil.launchUrl(
|
||||
'https://livinglist.github.io'),
|
||||
Constants.portfolioLink,
|
||||
),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(
|
||||
@ -401,7 +385,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => LinkUtil.launchUrl(
|
||||
'https://github.com/Livinglist/Hacki'),
|
||||
Constants.githubLink,
|
||||
),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(
|
||||
@ -414,6 +399,24 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => LinkUtil.launchUrl(
|
||||
Platform.isIOS
|
||||
? Constants.appStoreLink
|
||||
: Constants.googlePlayLink,
|
||||
),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.thumb_up,
|
||||
),
|
||||
SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Text('Like the App?'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@ -465,13 +468,13 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
CustomChip(
|
||||
label: 'Inbox : '
|
||||
//ignore: lines_longer_than_80_chars
|
||||
// ignore: lines_longer_than_80_chars
|
||||
'${notificationState.unreadCommentsIds.length}',
|
||||
selected: pageType == PageType.notification,
|
||||
selected: pageType == _PageType.notification,
|
||||
onSelected: (val) {
|
||||
if (val) {
|
||||
setState(() {
|
||||
pageType = PageType.notification;
|
||||
pageType = _PageType.notification;
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -481,11 +484,11 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
CustomChip(
|
||||
label: 'Favorite',
|
||||
selected: pageType == PageType.fav,
|
||||
selected: pageType == _PageType.fav,
|
||||
onSelected: (val) {
|
||||
if (val) {
|
||||
setState(() {
|
||||
pageType = PageType.fav;
|
||||
pageType = _PageType.fav;
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -495,11 +498,11 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
CustomChip(
|
||||
label: 'Submitted',
|
||||
selected: pageType == PageType.history,
|
||||
selected: pageType == _PageType.history,
|
||||
onSelected: (val) {
|
||||
if (val) {
|
||||
setState(() {
|
||||
pageType = PageType.history;
|
||||
pageType = _PageType.history;
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -509,11 +512,11 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
CustomChip(
|
||||
label: 'Search',
|
||||
selected: pageType == PageType.search,
|
||||
selected: pageType == _PageType.search,
|
||||
onSelected: (val) {
|
||||
if (val) {
|
||||
setState(() {
|
||||
pageType = PageType.search;
|
||||
pageType = _PageType.search;
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -523,11 +526,11 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
CustomChip(
|
||||
label: 'Settings',
|
||||
selected: pageType == PageType.settings,
|
||||
selected: pageType == _PageType.settings,
|
||||
onSelected: (val) {
|
||||
if (val) {
|
||||
setState(() {
|
||||
pageType = PageType.settings;
|
||||
pageType = _PageType.settings;
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -591,6 +594,30 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void onCommentTapped(Comment comment, {VoidCallback? then}) {
|
||||
throttle.run(() {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchParentStoryWithComments(id: comment.parent)
|
||||
.then((tuple) {
|
||||
if (tuple != null && mounted) {
|
||||
HackiApp.navigatorKey.currentState!
|
||||
.pushNamed(
|
||||
StoryScreen.routeName,
|
||||
arguments: StoryScreenArgs(
|
||||
story: tuple.item1,
|
||||
targetComments: tuple.item2.isEmpty
|
||||
? [comment]
|
||||
: [comment, ...tuple.item2],
|
||||
onlyShowTargetComment: true,
|
||||
),
|
||||
)
|
||||
.then((_) => then?.call());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void onLoginTapped() {
|
||||
final usernameController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
|
26
lib/screens/profile/widgets/centered_message_view.dart
Normal file
@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CenteredMessageView extends StatelessWidget {
|
||||
const CenteredMessageView({
|
||||
Key? key,
|
||||
required this.content,
|
||||
}) : super(key: key);
|
||||
|
||||
final String content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 120,
|
||||
left: 40,
|
||||
right: 40,
|
||||
),
|
||||
child: Text(
|
||||
content,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
49
lib/screens/profile/widgets/offline_list_tile.dart
Normal file
@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:wakelock/wakelock.dart';
|
||||
|
||||
class OfflineListTile extends StatelessWidget {
|
||||
const OfflineListTile({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<StoriesBloc, StoriesState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.downloadStatus != current.downloadStatus,
|
||||
listener: (context, state) {
|
||||
if (state.downloadStatus == StoriesDownloadStatus.failure) {
|
||||
Wakelock.disable();
|
||||
}
|
||||
},
|
||||
buildWhen: (previous, current) =>
|
||||
previous.downloadStatus != current.downloadStatus,
|
||||
builder: (context, state) {
|
||||
final downloading =
|
||||
state.downloadStatus == StoriesDownloadStatus.downloading;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
downloading ? 'Downloading All Stories...' : 'Download All Stories',
|
||||
),
|
||||
subtitle: const Text(
|
||||
'download all latest stories that have at least one comment '
|
||||
"for offline reading. (web page won't be downloaded)",
|
||||
),
|
||||
trailing: downloading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CustomCircularProgressIndicator(),
|
||||
)
|
||||
: const Icon(Icons.download),
|
||||
isThreeLine: true,
|
||||
onTap: () {
|
||||
Wakelock.enable();
|
||||
context.read<StoriesBloc>().add(StoriesDownload());
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -1,2 +1,4 @@
|
||||
export 'centered_message_view.dart';
|
||||
export 'custom_chip.dart';
|
||||
export 'inbox_view.dart';
|
||||
export 'offline_list_tile.dart';
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
@ -6,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
@ -28,13 +31,21 @@ enum _MenuAction {
|
||||
}
|
||||
|
||||
class StoryScreenArgs {
|
||||
StoryScreenArgs({required this.story});
|
||||
StoryScreenArgs({
|
||||
required this.story,
|
||||
this.onlyShowTargetComment = false,
|
||||
this.targetComments,
|
||||
});
|
||||
|
||||
final Story story;
|
||||
final bool onlyShowTargetComment;
|
||||
final List<Comment>? targetComments;
|
||||
}
|
||||
|
||||
class StoryScreen extends StatefulWidget {
|
||||
const StoryScreen({Key? key, required this.story}) : super(key: key);
|
||||
const StoryScreen(
|
||||
{Key? key, required this.story, required this.parentComments})
|
||||
: super(key: key);
|
||||
|
||||
static const String routeName = '/story';
|
||||
|
||||
@ -48,8 +59,12 @@ class StoryScreen extends StatefulWidget {
|
||||
),
|
||||
BlocProvider<CommentsCubit>(
|
||||
create: (_) => CommentsCubit<Story>(
|
||||
offlineReading: context.read<StoriesBloc>().state.offlineReading,
|
||||
item: args.story,
|
||||
),
|
||||
)..init(
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetComment: args.targetComments?.last,
|
||||
),
|
||||
),
|
||||
BlocProvider<EditCubit>(
|
||||
create: (context) => EditCubit(),
|
||||
@ -57,12 +72,14 @@ class StoryScreen extends StatefulWidget {
|
||||
],
|
||||
child: StoryScreen(
|
||||
story: args.story,
|
||||
parentComments: args.targetComments ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Story story;
|
||||
final List<Comment> parentComments;
|
||||
|
||||
@override
|
||||
_StoryScreenState createState() => _StoryScreenState();
|
||||
@ -105,6 +122,7 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
const <String>{
|
||||
Constants.featurePinToTop,
|
||||
Constants.featureAddStoryToFavList,
|
||||
Constants.featureOpenStoryInWebView,
|
||||
},
|
||||
@ -136,18 +154,16 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
final editCubit = context.read<EditCubit>();
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, authState) {
|
||||
return BlocConsumer<PostCubit, PostState>(
|
||||
return BlocListener<PostCubit, PostState>(
|
||||
listener: (context, postState) {
|
||||
if (postState.status == PostStatus.successful) {
|
||||
editCubit.onReplySubmittedSuccessfully();
|
||||
final verb =
|
||||
editCubit.state.replyingTo == null ? 'updated' : 'submitted';
|
||||
final msg = 'Comment $verb! ${(happyFaces..shuffle()).first}';
|
||||
focusNode.unfocus();
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
'Comment submitted! ${(happyFaces..shuffle()).first}',
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
));
|
||||
showSnackBar(content: msg);
|
||||
editCubit.onReplySubmittedSuccessfully();
|
||||
context.read<PostCubit>().reset();
|
||||
} else if (postState.status == PostStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
@ -163,327 +179,340 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
context.read<PostCubit>().reset();
|
||||
}
|
||||
},
|
||||
builder: (context, postState) {
|
||||
return BlocConsumer<CommentsCubit, CommentsState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == CommentsStatus.loaded) {
|
||||
refreshController
|
||||
..refreshCompleted()
|
||||
..loadComplete();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return BlocConsumer<EditCubit, EditState>(
|
||||
listenWhen: (previous, current) {
|
||||
return previous.replyingTo != current.replyingTo;
|
||||
},
|
||||
listener: (context, editState) {
|
||||
if (editState.replyingTo != null) {
|
||||
if (editState.text == null) {
|
||||
commentEditingController.clear();
|
||||
} else {
|
||||
final text = editState.text!;
|
||||
commentEditingController
|
||||
..text = text
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: text.length));
|
||||
}
|
||||
} else {
|
||||
child: BlocConsumer<CommentsCubit, CommentsState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.status != current.status,
|
||||
listener: (context, state) {
|
||||
if (state.status == CommentsStatus.loaded) {
|
||||
refreshController
|
||||
..refreshCompleted()
|
||||
..loadComplete();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return BlocListener<EditCubit, EditState>(
|
||||
listenWhen: (previous, current) {
|
||||
return previous.replyingTo != current.replyingTo ||
|
||||
previous.itemBeingEdited != current.itemBeingEdited;
|
||||
},
|
||||
listener: (context, editState) {
|
||||
if (editState.replyingTo != null ||
|
||||
editState.itemBeingEdited != null) {
|
||||
if (editState.text == null) {
|
||||
commentEditingController.clear();
|
||||
} else {
|
||||
final text = editState.text!;
|
||||
commentEditingController
|
||||
..text = text
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: text.length));
|
||||
}
|
||||
},
|
||||
builder: (context, editState) {
|
||||
final replyingTo = editCubit.state.replyingTo;
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor:
|
||||
Theme.of(context).canvasColor.withOpacity(0.6),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
ScrollUpIconButton(
|
||||
scrollController: scrollController,
|
||||
),
|
||||
BlocBuilder<FavCubit, FavState>(
|
||||
builder: (context, favState) {
|
||||
final isFav =
|
||||
favState.favIds.contains(widget.story.id);
|
||||
return IconButton(
|
||||
icon: DescribedFeatureOverlay(
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: Icon(
|
||||
isFav
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
color: Colors.white,
|
||||
),
|
||||
featureId: Constants.featureAddStoryToFavList,
|
||||
title: const Text('Fav a Story'),
|
||||
description: const Text(
|
||||
'Save this article for later.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
child: Icon(
|
||||
isFav
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
color: isFav
|
||||
? Colors.orange
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
if (isFav) {
|
||||
context
|
||||
.read<FavCubit>()
|
||||
.removeFav(widget.story.id);
|
||||
} else {
|
||||
context
|
||||
.read<FavCubit>()
|
||||
.addFav(widget.story.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: DescribedFeatureOverlay(
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: const Icon(
|
||||
Icons.stream,
|
||||
color: Colors.white,
|
||||
),
|
||||
featureId: Constants.featureOpenStoryInWebView,
|
||||
title: const Text('Open in Browser'),
|
||||
description: const Text(
|
||||
'Want more than just reading and replying? '
|
||||
'You can tap here to open this story in a '
|
||||
'browser.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.stream,
|
||||
),
|
||||
),
|
||||
onPressed: () => LinkUtil.launchUrl(
|
||||
'https://news.ycombinator.com/item?id=${widget.story.id}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SmartRefresher(
|
||||
} else {
|
||||
commentEditingController.clear();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor:
|
||||
Theme.of(context).canvasColor.withOpacity(0.6),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
ScrollUpIconButton(
|
||||
scrollController: scrollController,
|
||||
enablePullUp: true,
|
||||
header: WaterDropMaterialHeader(
|
||||
backgroundColor: Colors.orange,
|
||||
offset: topPadding,
|
||||
),
|
||||
PinIconButton(story: widget.story),
|
||||
FavIconButton(storyId: widget.story.id),
|
||||
LinkIconButton(storyId: widget.story.id),
|
||||
],
|
||||
),
|
||||
body: SmartRefresher(
|
||||
scrollController: scrollController,
|
||||
enablePullUp: !state.onlyShowTargetComment,
|
||||
enablePullDown: !state.onlyShowTargetComment,
|
||||
header: WaterDropMaterialHeader(
|
||||
backgroundColor: Colors.orange,
|
||||
offset: topPadding,
|
||||
),
|
||||
footer: CustomFooter(
|
||||
loadStyle: LoadStyle.ShowWhenLoading,
|
||||
builder: (context, mode) {
|
||||
Widget body;
|
||||
if (mode == LoadStatus.idle) {
|
||||
body = const Text('');
|
||||
} else if (mode == LoadStatus.loading) {
|
||||
body = const Text('');
|
||||
} else if (mode == LoadStatus.failed) {
|
||||
body = const Text(
|
||||
'',
|
||||
);
|
||||
} else if (mode == LoadStatus.canLoading) {
|
||||
body = const Text(
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
body = const Text('');
|
||||
}
|
||||
return SizedBox(
|
||||
height: 55,
|
||||
child: Center(child: body),
|
||||
);
|
||||
},
|
||||
),
|
||||
controller: refreshController,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
locator.get<CacheService>().resetComments();
|
||||
context.read<CommentsCubit>().refresh();
|
||||
},
|
||||
onLoading: () {},
|
||||
child: ListView(
|
||||
primary: false,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: topPadding,
|
||||
),
|
||||
footer: CustomFooter(
|
||||
loadStyle: LoadStyle.ShowWhenLoading,
|
||||
builder: (context, mode) {
|
||||
Widget body;
|
||||
if (mode == LoadStatus.idle) {
|
||||
body = const Text('');
|
||||
} else if (mode == LoadStatus.loading) {
|
||||
body = const Text('');
|
||||
} else if (mode == LoadStatus.failed) {
|
||||
body = const Text(
|
||||
'',
|
||||
);
|
||||
} else if (mode == LoadStatus.canLoading) {
|
||||
body = const Text(
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
body = const Text('');
|
||||
}
|
||||
return SizedBox(
|
||||
height: 55,
|
||||
child: Center(child: body),
|
||||
);
|
||||
},
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 6),
|
||||
child: OfflineBanner(),
|
||||
),
|
||||
controller: refreshController,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
locator.get<CacheService>().resetComments();
|
||||
context.read<CommentsCubit>().refresh();
|
||||
},
|
||||
onLoading: () {},
|
||||
child: ListView(
|
||||
primary: false,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: topPadding,
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (widget.story != replyingTo) {
|
||||
Slidable(
|
||||
startActionPane: ActionPane(
|
||||
motion: const BehindMotion(),
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (_) {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
if (widget.story !=
|
||||
context
|
||||
.read<EditCubit>()
|
||||
.state
|
||||
.replyingTo) {
|
||||
commentEditingController.clear();
|
||||
}
|
||||
editCubit.onItemTapped(widget.story);
|
||||
editCubit.onReplyTapped(widget.story);
|
||||
focusNode.requestFocus();
|
||||
});
|
||||
},
|
||||
onLongPress: () => onLongPressed(widget.story),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 6,
|
||||
right: 6,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.story.by,
|
||||
style: const TextStyle(
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
widget.story.postedDate,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => LinkUtil.launchUrl(
|
||||
widget.story.url,
|
||||
useReader: context
|
||||
.read<PreferenceCubit>()
|
||||
.state
|
||||
.useReader,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 6,
|
||||
right: 6,
|
||||
bottom: 12,
|
||||
top: 12,
|
||||
),
|
||||
child: Text(
|
||||
widget.story.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.story.text.isNotEmpty)
|
||||
Html(
|
||||
data: widget.story.text,
|
||||
onLinkTap: (link, _, __, ___) =>
|
||||
LinkUtil.launchUrl(link ?? ''),
|
||||
),
|
||||
],
|
||||
},
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.message,
|
||||
),
|
||||
),
|
||||
const Divider(
|
||||
height: 0,
|
||||
),
|
||||
if (state.comments.isEmpty &&
|
||||
state.status == CommentsStatus.loaded) ...[
|
||||
const SizedBox(
|
||||
height: 240,
|
||||
),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Nothing yet',
|
||||
style: TextStyle(color: Colors.white30),
|
||||
),
|
||||
SlidableAction(
|
||||
onPressed: (_) => onMorePressed(widget.story),
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.more_horiz,
|
||||
),
|
||||
],
|
||||
...state.comments.map(
|
||||
(e) => FadeIn(
|
||||
child: CommentTile(
|
||||
comment: e,
|
||||
myUsername: authState.isLoggedIn
|
||||
? authState.username
|
||||
: null,
|
||||
onTap: (cmt) {
|
||||
if (cmt.deleted || cmt.dead) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmt != replyingTo) {
|
||||
commentEditingController.clear();
|
||||
}
|
||||
|
||||
editCubit.onItemTapped(cmt);
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
onLongPress: onLongPressed,
|
||||
onStoryLinkTapped: (link) {
|
||||
final regex = RegExp(r'\d+$');
|
||||
final match = regex.stringMatch(link) ?? '';
|
||||
final id = int.tryParse(match);
|
||||
if (id != null) {
|
||||
throttle.run(() {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchParentStory(id: id)
|
||||
.then((story) {
|
||||
if (mounted) {
|
||||
if (story != null) {
|
||||
HackiApp
|
||||
.navigatorKey.currentState!
|
||||
.pushNamed(
|
||||
StoryScreen.routeName,
|
||||
arguments: StoryScreenArgs(
|
||||
story: story),
|
||||
);
|
||||
} else {}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
LinkUtil.launchUrl(link);
|
||||
}
|
||||
},
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 6,
|
||||
right: 6,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.story.by,
|
||||
style: const TextStyle(
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
widget.story.postedDate,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 240,
|
||||
),
|
||||
],
|
||||
InkWell(
|
||||
onTap: () => LinkUtil.launchUrl(
|
||||
widget.story.url,
|
||||
useReader: context
|
||||
.read<PreferenceCubit>()
|
||||
.state
|
||||
.useReader,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 6,
|
||||
right: 6,
|
||||
bottom: 12,
|
||||
top: 12,
|
||||
),
|
||||
child: Text(
|
||||
widget.story.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.story.text.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
),
|
||||
child: SelectableHtml(
|
||||
data: widget.story.text,
|
||||
onLinkTap: (link, _, __, ___) =>
|
||||
LinkUtil.launchUrl(link ?? ''),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomSheet: Offstage(
|
||||
offstage: !editCubit.state.showReplyBox,
|
||||
child: ReplyBox(
|
||||
focusNode: focusNode,
|
||||
textEditingController: commentEditingController,
|
||||
replyingTo: replyingTo,
|
||||
isLoading: postState.status == PostStatus.loading,
|
||||
onSendTapped: onSendTapped,
|
||||
onCloseTapped: () {
|
||||
editCubit.onReplyBoxClosed();
|
||||
commentEditingController.clear();
|
||||
focusNode.unfocus();
|
||||
if (widget.story.text.isNotEmpty)
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const Divider(
|
||||
height: 0,
|
||||
),
|
||||
if (state.onlyShowTargetComment) ...[
|
||||
TextButton(
|
||||
onPressed: () => context
|
||||
.read<CommentsCubit>()
|
||||
.loadAll(widget.story),
|
||||
child: const Text('View all comments'),
|
||||
),
|
||||
const Divider(
|
||||
height: 0,
|
||||
),
|
||||
],
|
||||
if (state.comments.isEmpty &&
|
||||
state.status == CommentsStatus.loaded) ...[
|
||||
const SizedBox(
|
||||
height: 240,
|
||||
),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Nothing yet',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
...state.comments.map(
|
||||
(e) => FadeIn(
|
||||
child: CommentTile(
|
||||
comment: e,
|
||||
onlyShowTargetComment:
|
||||
state.onlyShowTargetComment,
|
||||
targetComments: widget.parentComments.sublist(
|
||||
0, max(widget.parentComments.length - 1, 0)),
|
||||
myUsername: authState.isLoggedIn
|
||||
? authState.username
|
||||
: null,
|
||||
onReplyTapped: (cmt) {
|
||||
HapticFeedback.lightImpact();
|
||||
if (cmt.deleted || cmt.dead) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmt !=
|
||||
context
|
||||
.read<EditCubit>()
|
||||
.state
|
||||
.replyingTo) {
|
||||
commentEditingController.clear();
|
||||
}
|
||||
|
||||
editCubit.onReplyTapped(cmt);
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
onEditTapped: (cmt) {
|
||||
HapticFeedback.lightImpact();
|
||||
if (cmt.deleted || cmt.dead) {
|
||||
return;
|
||||
}
|
||||
commentEditingController.clear();
|
||||
editCubit.onEditTapped(cmt);
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
onMoreTapped: onMorePressed,
|
||||
onStoryLinkTapped: (link) {
|
||||
final regex = RegExp(r'\d+$');
|
||||
final match = regex.stringMatch(link) ?? '';
|
||||
final id = int.tryParse(match);
|
||||
if (id != null) {
|
||||
throttle.run(() {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchParentStory(id: id)
|
||||
.then((story) {
|
||||
if (mounted) {
|
||||
if (story != null) {
|
||||
HackiApp.navigatorKey.currentState!
|
||||
.pushNamed(
|
||||
StoryScreen.routeName,
|
||||
arguments:
|
||||
StoryScreenArgs(story: story),
|
||||
);
|
||||
} else {}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
LinkUtil.launchUrl(link);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 240,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomSheet: Offstage(
|
||||
offstage: !editCubit.state.showReplyBox,
|
||||
child: BlocBuilder<PostCubit, PostState>(
|
||||
builder: (context, postState) {
|
||||
return BlocBuilder<EditCubit, EditState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.itemBeingEdited !=
|
||||
current.itemBeingEdited ||
|
||||
previous.replyingTo != current.replyingTo,
|
||||
builder: (context, editState) {
|
||||
return ReplyBox(
|
||||
focusNode: focusNode,
|
||||
textEditingController: commentEditingController,
|
||||
editing: editState.itemBeingEdited,
|
||||
replyingTo: editState.replyingTo,
|
||||
isLoading: postState.status == PostStatus.loading,
|
||||
onSendTapped: onSendTapped,
|
||||
onCloseTapped: () {
|
||||
editCubit.onReplyBoxClosed();
|
||||
commentEditingController.clear();
|
||||
focusNode.unfocus();
|
||||
},
|
||||
onChanged: editCubit.onTextChanged,
|
||||
);
|
||||
},
|
||||
onChanged: editCubit.onTextChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void onLongPressed(Item item) {
|
||||
void onMorePressed(Item item) {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
if (item.dead || item.deleted) {
|
||||
return;
|
||||
}
|
||||
@ -549,6 +578,9 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
? const TextStyle(color: Colors.orange)
|
||||
: null,
|
||||
),
|
||||
subtitle: item is Story
|
||||
? Text(item.score.toString())
|
||||
: null,
|
||||
onTap: context.read<VoteCubit>().upvote,
|
||||
),
|
||||
ListTile(
|
||||
@ -680,10 +712,7 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
}).then((yesTapped) {
|
||||
if (yesTapped ?? false) {
|
||||
context.read<AuthBloc>().add(AuthFlag(item: item));
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Comment flagged!'),
|
||||
backgroundColor: Colors.orange,
|
||||
));
|
||||
showSnackBar(content: 'Comment flagged!');
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -752,10 +781,7 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
} else {
|
||||
context.read<BlocklistCubit>().addToBlocklist(item.by);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('User ${isBlocked ? 'unblocked' : 'blocked'}!'),
|
||||
backgroundColor: Colors.orange,
|
||||
));
|
||||
showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -763,7 +789,9 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
void onSendTapped() {
|
||||
final authBloc = context.read<AuthBloc>();
|
||||
final postCubit = context.read<PostCubit>();
|
||||
final replyingTo = context.read<EditCubit>().state.replyingTo;
|
||||
final editState = context.read<EditCubit>().state;
|
||||
final replyingTo = editState.replyingTo;
|
||||
final itemEdited = editState.itemBeingEdited;
|
||||
|
||||
if (authBloc.state.isLoggedIn) {
|
||||
final text = commentEditingController.text;
|
||||
@ -771,7 +799,9 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (replyingTo != null) {
|
||||
if (itemEdited != null) {
|
||||
postCubit.edit(text: text, id: itemEdited.id);
|
||||
} else if (replyingTo != null) {
|
||||
postCubit.post(text: text, to: replyingTo.id);
|
||||
}
|
||||
} else {
|
||||
@ -792,12 +822,7 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
listener: (context, state) {
|
||||
if (state.isLoggedIn) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Logged in successfully! $happyFace'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
showSnackBar(content: 'Logged in successfully! $happyFace');
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
|
53
lib/screens/story/widgets/fav_icon_button.dart
Normal file
@ -0,0 +1,53 @@
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
|
||||
class FavIconButton extends StatelessWidget {
|
||||
const FavIconButton({
|
||||
Key? key,
|
||||
required this.storyId,
|
||||
}) : super(key: key);
|
||||
|
||||
final int storyId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<FavCubit, FavState>(
|
||||
builder: (context, favState) {
|
||||
final isFav = favState.favIds.contains(storyId);
|
||||
return IconButton(
|
||||
icon: DescribedFeatureOverlay(
|
||||
barrierDismissible: false,
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_border,
|
||||
color: Colors.white,
|
||||
),
|
||||
featureId: Constants.featureAddStoryToFavList,
|
||||
title: const Text('Fav a Story'),
|
||||
description: const Text(
|
||||
'Add it to your favorites.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
child: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFav ? Colors.orange : Theme.of(context).iconTheme.color,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
if (isFav) {
|
||||
context.read<FavCubit>().removeFav(storyId);
|
||||
} else {
|
||||
context.read<FavCubit>().addFav(storyId);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
41
lib/screens/story/widgets/link_icon_button.dart
Normal file
@ -0,0 +1,41 @@
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class LinkIconButton extends StatelessWidget {
|
||||
const LinkIconButton({
|
||||
Key? key,
|
||||
required this.storyId,
|
||||
}) : super(key: key);
|
||||
|
||||
final int storyId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: DescribedFeatureOverlay(
|
||||
barrierDismissible: false,
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: const Icon(
|
||||
Icons.stream,
|
||||
color: Colors.white,
|
||||
),
|
||||
featureId: Constants.featureOpenStoryInWebView,
|
||||
title: const Text('Open in Browser'),
|
||||
description: const Text(
|
||||
'Want more than just reading and replying? '
|
||||
'You can tap here to open this story in a '
|
||||
'browser.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.stream,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
LinkUtil.launchUrl('https://news.ycombinator.com/item?id=$storyId'),
|
||||
);
|
||||
}
|
||||
}
|
66
lib/screens/story/widgets/pin_icon_button.dart
Normal file
@ -0,0 +1,66 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
|
||||
class PinIconButton extends StatelessWidget {
|
||||
const PinIconButton({
|
||||
Key? key,
|
||||
required this.story,
|
||||
}) : super(key: key);
|
||||
|
||||
final Story story;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PinCubit, PinState>(
|
||||
builder: (context, pinState) {
|
||||
final pinned = pinState.pinnedStoriesIds.contains(story.id);
|
||||
return Transform.rotate(
|
||||
angle: pi / 4,
|
||||
child: Transform.translate(
|
||||
offset: const Offset(2, 0),
|
||||
child: IconButton(
|
||||
icon: DescribedFeatureOverlay(
|
||||
barrierDismissible: false,
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: Icon(
|
||||
pinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
color: Colors.white,
|
||||
),
|
||||
featureId: Constants.featurePinToTop,
|
||||
title: const Text('Pin a Story'),
|
||||
description: const Text(
|
||||
'Pin this story to the top of your '
|
||||
'home screen so that you can come'
|
||||
' back later.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
child: Icon(
|
||||
pinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
color: pinned
|
||||
? Colors.orange
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
if (pinned) {
|
||||
context.read<PinCubit>().unpinStory(story);
|
||||
} else {
|
||||
context.read<PinCubit>().pinStory(story);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -11,6 +11,7 @@ class ReplyBox extends StatefulWidget {
|
||||
required this.focusNode,
|
||||
required this.textEditingController,
|
||||
required this.replyingTo,
|
||||
required this.editing,
|
||||
required this.onSendTapped,
|
||||
required this.onCloseTapped,
|
||||
required this.onChanged,
|
||||
@ -20,6 +21,7 @@ class ReplyBox extends StatefulWidget {
|
||||
final FocusNode focusNode;
|
||||
final TextEditingController textEditingController;
|
||||
final Item? replyingTo;
|
||||
final Item? editing;
|
||||
final VoidCallback onSendTapped;
|
||||
final VoidCallback onCloseTapped;
|
||||
final ValueChanged<String> onChanged;
|
||||
@ -66,42 +68,45 @@ class _ReplyBoxState extends State<ReplyBox> {
|
||||
),
|
||||
child: Text(
|
||||
widget.replyingTo == null
|
||||
? ''
|
||||
? 'Editing'
|
||||
: 'Replying '
|
||||
'${widget.replyingTo?.by}',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.replyingTo != null && !widget.isLoading) ...[
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: IconButton(
|
||||
key: const Key('quote'),
|
||||
icon: const Icon(
|
||||
FeatherIcons.code,
|
||||
if (!widget.isLoading) ...[
|
||||
...[
|
||||
if (widget.replyingTo != null)
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: IconButton(
|
||||
key: const Key('quote'),
|
||||
icon: const Icon(
|
||||
FeatherIcons.code,
|
||||
color: Colors.orange,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: expanded ? showTextPopup : null,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('expand'),
|
||||
icon: Icon(
|
||||
expanded
|
||||
? FeatherIcons.minimize2
|
||||
: FeatherIcons.maximize2,
|
||||
color: Colors.orange,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: expanded ? showTextPopup : null,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
expanded = !expanded;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('expand'),
|
||||
icon: Icon(
|
||||
expanded
|
||||
? FeatherIcons.minimize2
|
||||
: FeatherIcons.maximize2,
|
||||
color: Colors.orange,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
expanded = !expanded;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
key: const Key('close'),
|
||||
icon: const Icon(
|
||||
|
@ -1,2 +1,5 @@
|
||||
export 'fav_icon_button.dart';
|
||||
export 'link_icon_button.dart';
|
||||
export 'pin_icon_button.dart';
|
||||
export 'reply_box.dart';
|
||||
export 'scroll_up_icon_button.dart';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
|
||||
@ -26,12 +27,22 @@ class _SubmitScreenState extends State<SubmitScreen> {
|
||||
final urlEditingController = TextEditingController();
|
||||
final textEditingController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
titleEditingController.dispose();
|
||||
urlEditingController.dispose();
|
||||
textEditingController.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<SubmitCubit, SubmitState>(
|
||||
listenWhen: (previous, current) => previous.status != current.status,
|
||||
listener: (context, state) {
|
||||
if (state.status == SubmitStatus.submitted) {
|
||||
Navigator.pop(context);
|
||||
HapticFeedback.lightImpact();
|
||||
showSnackBar(
|
||||
content: 'Post submitted successfully.',
|
||||
);
|
||||
@ -123,6 +134,7 @@ class _SubmitScreenState extends State<SubmitScreen> {
|
||||
controller: titleEditingController,
|
||||
cursorColor: Colors.orange,
|
||||
autocorrect: false,
|
||||
maxLength: 80,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Title',
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
|
@ -1,7 +1,12 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
@ -11,25 +16,38 @@ class CommentTile extends StatelessWidget {
|
||||
Key? key,
|
||||
required this.myUsername,
|
||||
required this.comment,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
required this.onReplyTapped,
|
||||
required this.onMoreTapped,
|
||||
required this.onEditTapped,
|
||||
required this.onStoryLinkTapped,
|
||||
this.loadKids = true,
|
||||
this.onlyShowTargetComment = false,
|
||||
this.level = 0,
|
||||
this.targetComments = const [],
|
||||
}) : super(key: key);
|
||||
|
||||
final String? myUsername;
|
||||
final Comment comment;
|
||||
final int level;
|
||||
final bool loadKids;
|
||||
final Function(Comment) onTap;
|
||||
final Function(Comment) onLongPress;
|
||||
final bool onlyShowTargetComment;
|
||||
final Function(Comment) onReplyTapped;
|
||||
final Function(Comment) onMoreTapped;
|
||||
final Function(Comment) onEditTapped;
|
||||
final Function(String) onStoryLinkTapped;
|
||||
final List<Comment> targetComments;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CommentsCubit>(
|
||||
create: (_) => CommentsCubit<Comment>(item: comment),
|
||||
lazy: false,
|
||||
create: (_) => CommentsCubit<Comment>(
|
||||
offlineReading: context.read<StoriesBloc>().state.offlineReading,
|
||||
item: comment,
|
||||
)..init(
|
||||
onlyShowTargetComment: onlyShowTargetComment,
|
||||
targetComment: targetComments.isNotEmpty ? targetComments.last : null,
|
||||
),
|
||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||
builder: (context, state) {
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
@ -58,37 +76,64 @@ class CommentTile extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => onTap(comment),
|
||||
onLongPress: () => onLongPress(comment),
|
||||
onDoubleTap: () {
|
||||
context.read<CommentsCubit>().collapse();
|
||||
},
|
||||
Slidable(
|
||||
startActionPane: ActionPane(
|
||||
motion: const BehindMotion(),
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (_) => onReplyTapped(comment),
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.message,
|
||||
),
|
||||
if (context.read<AuthBloc>().state.user.id ==
|
||||
comment.by)
|
||||
SlidableAction(
|
||||
onPressed: (_) => onEditTapped(comment),
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.edit,
|
||||
),
|
||||
SlidableAction(
|
||||
onPressed: (_) => onMoreTapped(comment),
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.more_horiz,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 6, right: 6, top: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
comment.by,
|
||||
style: TextStyle(
|
||||
//255, 152, 0
|
||||
color: prefState.showEyeCandy
|
||||
? orange
|
||||
: color,
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
context.read<CommentsCubit>().collapse();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 6, right: 6, top: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
comment.by,
|
||||
style: TextStyle(
|
||||
//255, 152, 0
|
||||
color: prefState.showEyeCandy
|
||||
? orange
|
||||
: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
comment.postedDate,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
const Spacer(),
|
||||
Text(
|
||||
comment.postedDate,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (comment.deleted)
|
||||
@ -141,7 +186,7 @@ class CommentTile extends StatelessWidget {
|
||||
top: 6,
|
||||
bottom: 12,
|
||||
),
|
||||
child: Linkify(
|
||||
child: SelectableLinkify(
|
||||
key: ObjectKey(comment),
|
||||
text: comment.text,
|
||||
onOpen: (link) {
|
||||
@ -164,20 +209,28 @@ class CommentTile extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Column(
|
||||
children: state.comments
|
||||
.map(
|
||||
(e) => FadeIn(
|
||||
child: CommentTile(
|
||||
comment: e,
|
||||
myUsername: myUsername,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
level: level + 1,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
),
|
||||
children: [
|
||||
...state.comments.map(
|
||||
(e) => FadeIn(
|
||||
child: CommentTile(
|
||||
comment: e,
|
||||
onlyShowTargetComment:
|
||||
onlyShowTargetComment &&
|
||||
targetComments.length > 1,
|
||||
targetComments: targetComments.isNotEmpty
|
||||
? targetComments.sublist(0,
|
||||
max(targetComments.length - 1, 0))
|
||||
: [],
|
||||
myUsername: myUsername,
|
||||
onReplyTapped: onReplyTapped,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onEditTapped: onEditTapped,
|
||||
level: level + 1,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -195,9 +248,8 @@ class CommentTile extends StatelessWidget {
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? 0.03
|
||||
: 0.15;
|
||||
final borderColor = prefState.showCommentBorder && level != 0
|
||||
? color.withOpacity(0.5)
|
||||
: Colors.transparent;
|
||||
final borderColor =
|
||||
level != 0 ? color.withOpacity(0.5) : Colors.transparent;
|
||||
final commentColor = prefState.showEyeCandy
|
||||
? color.withOpacity(commentBackgroundColorOpacity)
|
||||
: Colors.transparent;
|
||||
|
@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/custom_circular_progress_indicator.dart';
|
||||
import 'package:hacki/screens/widgets/story_tile.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
@ -17,6 +19,9 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
required this.refreshController,
|
||||
this.enablePullDown = true,
|
||||
this.pinnable = false,
|
||||
this.markReadStories = false,
|
||||
this.useConsistentFontSize = false,
|
||||
this.showOfflineBanner = false,
|
||||
this.onRefresh,
|
||||
this.onLoadMore,
|
||||
this.onPinned,
|
||||
@ -26,9 +31,15 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
|
||||
final bool showWebPreview;
|
||||
final bool enablePullDown;
|
||||
final bool markReadStories;
|
||||
final bool showOfflineBanner;
|
||||
|
||||
/// Whether story tiles can be pinned to the top.
|
||||
final bool pinnable;
|
||||
|
||||
/// Whether to use same font size for comment and story tiles.
|
||||
final bool useConsistentFontSize;
|
||||
|
||||
final List<T> items;
|
||||
final Widget? header;
|
||||
final RefreshController? refreshController;
|
||||
@ -41,8 +52,14 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final child = ListView(
|
||||
children: [
|
||||
if (showOfflineBanner)
|
||||
const OfflineBanner(
|
||||
showExitButton: true,
|
||||
),
|
||||
if (header != null) header!,
|
||||
...items.map((e) {
|
||||
final wasRead =
|
||||
context.read<CacheCubit>().state.storiesReadStatus[e.id] ?? false;
|
||||
if (e is Story) {
|
||||
return [
|
||||
FadeIn(
|
||||
@ -52,11 +69,14 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
motion: const BehindMotion(),
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (_) => onPinned?.call(e),
|
||||
onPressed: (_) {
|
||||
HapticFeedback.lightImpact();
|
||||
onPinned?.call(e);
|
||||
},
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
icon: showWebPreview
|
||||
? Icons.vertical_align_top
|
||||
? Icons.push_pin_outlined
|
||||
: null,
|
||||
label: 'Pin to top',
|
||||
),
|
||||
@ -68,6 +88,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
story: e,
|
||||
onTap: () => onTap(e),
|
||||
showWebPreview: showWebPreview,
|
||||
wasRead: markReadStories && wasRead,
|
||||
simpleTileFontSize: useConsistentFontSize ? 14 : 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -12,16 +12,34 @@ import 'package:http/io_client.dart';
|
||||
|
||||
abstract class InfoBase {
|
||||
late DateTime _timeout;
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
/// Web Information
|
||||
class WebInfo extends InfoBase {
|
||||
WebInfo({this.title, this.icon, this.description, this.image});
|
||||
|
||||
WebInfo.fromJson(Map<String, dynamic> json)
|
||||
: title = json['title'] as String?,
|
||||
icon = json['icon'] as String?,
|
||||
description = json['description'] as String?,
|
||||
image = json['image'] as String?;
|
||||
|
||||
final String? title;
|
||||
final String? icon;
|
||||
final String? description;
|
||||
final String? image;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'title': title,
|
||||
'icon': icon,
|
||||
'description': description,
|
||||
'image': image,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Image Information
|
||||
@ -29,6 +47,13 @@ class WebImageInfo extends InfoBase {
|
||||
WebImageInfo({this.image});
|
||||
|
||||
final String? image;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'image': image,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Video Information
|
||||
@ -137,9 +162,9 @@ class WebAnalyzer {
|
||||
}
|
||||
|
||||
static Future<List<dynamic>?> _isolate(dynamic message) async {
|
||||
//ignore: avoid_dynamic_calls
|
||||
// ignore: avoid_dynamic_calls
|
||||
final url = message[0] as String;
|
||||
//ignore: avoid_dynamic_calls
|
||||
// ignore: avoid_dynamic_calls
|
||||
final multimedia = message[1] as bool;
|
||||
|
||||
final info = await _getInfo(url, multimedia);
|
||||
|
74
lib/screens/widgets/offline_banner.dart
Normal file
@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
|
||||
class OfflineBanner extends StatelessWidget {
|
||||
const OfflineBanner({
|
||||
Key? key,
|
||||
this.showExitButton = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final bool showExitButton;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StoriesBloc, StoriesState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.offlineReading != current.offlineReading,
|
||||
builder: (context, state) {
|
||||
if (state.offlineReading) {
|
||||
return MaterialBanner(
|
||||
content: Text(
|
||||
'You are currently in offline mode. '
|
||||
'${showExitButton ? 'Exit to fetch latest stories.' : ''}',
|
||||
textAlign: showExitButton ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
backgroundColor: Colors.orangeAccent.withOpacity(0.3),
|
||||
actions: [
|
||||
if (showExitButton)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Exit offline mode?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(true),
|
||||
child: const Text(
|
||||
'Yes',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).then((value) {
|
||||
if (value ?? false) {
|
||||
context.read<StoriesBloc>().add(StoriesExitOffline());
|
||||
context.read<AuthBloc>().add(AuthInitialize());
|
||||
context.read<PinCubit>().init();
|
||||
}
|
||||
});
|
||||
},
|
||||
child: const Text('Exit'),
|
||||
)
|
||||
else
|
||||
Container(),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
75
lib/screens/widgets/stories_list_view.dart
Normal file
@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
class StoriesListView extends StatelessWidget {
|
||||
const StoriesListView({
|
||||
Key? key,
|
||||
required this.storyType,
|
||||
required this.header,
|
||||
required this.onStoryTapped,
|
||||
required this.refreshController,
|
||||
}) : super(key: key);
|
||||
|
||||
final StoryType storyType;
|
||||
final Widget header;
|
||||
final ValueChanged<Story> onStoryTapped;
|
||||
final RefreshController refreshController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.showComplexStoryTile != current.showComplexStoryTile,
|
||||
builder: (context, preferenceState) {
|
||||
return BlocConsumer<StoriesBloc, StoriesState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.statusByType[storyType] !=
|
||||
current.statusByType[storyType],
|
||||
listener: (context, state) {
|
||||
if (state.statusByType[storyType] == StoriesStatus.loaded) {
|
||||
refreshController
|
||||
..refreshCompleted(resetFooterState: true)
|
||||
..loadComplete();
|
||||
}
|
||||
},
|
||||
buildWhen: (previous, current) =>
|
||||
(current.currentPageByType[storyType] == 0 &&
|
||||
previous.currentPageByType[storyType] == 0) ||
|
||||
(previous.storiesByType[storyType]!.length !=
|
||||
current.storiesByType[storyType]!.length),
|
||||
builder: (context, state) {
|
||||
return ItemsListView<Story>(
|
||||
pinnable: true,
|
||||
showOfflineBanner: true,
|
||||
markReadStories:
|
||||
context.read<PreferenceCubit>().state.markReadStories,
|
||||
showWebPreview: preferenceState.showComplexStoryTile,
|
||||
refreshController: refreshController,
|
||||
items: state.storiesByType[storyType]!,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesRefresh(type: storyType));
|
||||
},
|
||||
onLoadMore: () {
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesLoadMore(type: storyType));
|
||||
},
|
||||
onTap: onStoryTapped,
|
||||
onPinned: context.read<PinCubit>().pinStory,
|
||||
header: state.offlineReading ? null : header,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -10,14 +10,18 @@ import 'package:shimmer/shimmer.dart';
|
||||
class StoryTile extends StatelessWidget {
|
||||
const StoryTile({
|
||||
Key? key,
|
||||
this.wasRead = false,
|
||||
required this.showWebPreview,
|
||||
required this.story,
|
||||
required this.onTap,
|
||||
this.simpleTileFontSize = 16,
|
||||
}) : super(key: key);
|
||||
|
||||
final bool showWebPreview;
|
||||
final bool wasRead;
|
||||
final Story story;
|
||||
final VoidCallback onTap;
|
||||
final double simpleTileFontSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -118,7 +122,9 @@ class StoryTile extends StatelessWidget {
|
||||
bodyMaxLines: 4,
|
||||
errorTitle: story.title,
|
||||
titleStyle: TextStyle(
|
||||
color: Theme.of(context).textTheme.subtitle1!.color,
|
||||
color: wasRead
|
||||
? Colors.grey[500]
|
||||
: Theme.of(context).textTheme.subtitle1!.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@ -145,7 +151,9 @@ class StoryTile extends StatelessWidget {
|
||||
imagePath: Constants.hackerNewsLogoPath,
|
||||
bodyMaxLines: 4,
|
||||
titleTextStyle: TextStyle(
|
||||
color: Theme.of(context).textTheme.subtitle1!.color,
|
||||
color: wasRead
|
||||
? Colors.grey[500]
|
||||
: Theme.of(context).textTheme.subtitle1!.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@ -170,7 +178,10 @@ class StoryTile extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
story.title,
|
||||
style: const TextStyle(fontSize: 15),
|
||||
style: TextStyle(
|
||||
color: wasRead ? Colors.grey[500] : null,
|
||||
fontSize: simpleTileFontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -3,6 +3,8 @@ export 'custom_circular_progress_indicator.dart';
|
||||
export 'items_list_view.dart';
|
||||
export 'link_preview/link_preview.dart';
|
||||
export 'link_preview/link_preview.dart';
|
||||
export 'offline_banner.dart';
|
||||
export 'spring_curve.dart';
|
||||
export 'stories_list_view.dart';
|
||||
export 'story_tile.dart';
|
||||
export 'tap_down_wrapper.dart';
|
||||
|
119
lib/services/firebase_client.dart
Normal file
@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
|
||||
/// FirebaseClient wraps a REST client for a Firebase realtime database.
|
||||
///
|
||||
/// The client supports authentication and GET, PUT, POST, DELETE
|
||||
/// and PATCH methods.
|
||||
class FirebaseClient {
|
||||
/// Creates a new FirebaseClient with [credential] and optional [client].
|
||||
///
|
||||
/// For credential you can either use Firebase app's secret or
|
||||
/// an authentication token.
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/user-auth>.
|
||||
FirebaseClient(this.credential, {Client? client})
|
||||
: _client = client ?? Client();
|
||||
|
||||
/// Creates a new anonymous FirebaseClient with optional [client].
|
||||
FirebaseClient.anonymous({Client? client})
|
||||
: credential = null,
|
||||
_client = client ?? Client();
|
||||
|
||||
/// Auth credential.
|
||||
final String? credential;
|
||||
final Client _client;
|
||||
|
||||
/// Reads data from database using a HTTP GET request.
|
||||
/// The response from a successful request contains a data being retrieved.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-get>.
|
||||
Future<dynamic> get(dynamic uri) => send('GET', uri);
|
||||
|
||||
/// Writes or replaces data in database using a HTTP PUT request.
|
||||
/// The response from a successful request contains a data being written.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-put>.
|
||||
Future<dynamic> put(dynamic uri, dynamic json) =>
|
||||
send('PUT', uri, json: json);
|
||||
|
||||
/// Pushes data to database using a HTTP POST request.
|
||||
/// The response from a successful request contains a key of the new data
|
||||
/// being added.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-post>.
|
||||
Future<dynamic> post(dynamic uri, dynamic json) =>
|
||||
send('POST', uri, json: json);
|
||||
|
||||
/// Updates specific children at a location without overwriting existing data
|
||||
/// using a HTTP PATCH request.
|
||||
/// The response from a successful request contains a data being written.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-patch>.
|
||||
Future<dynamic> patch(dynamic uri, dynamic json) =>
|
||||
send('PATCH', uri, json: json);
|
||||
|
||||
/// Deletes data from database using a HTTP DELETE request.
|
||||
/// The response from a successful request contains a JSON with `null`.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-delete>.
|
||||
Future<void> delete(dynamic uri) => send('DELETE', uri);
|
||||
|
||||
/// Creates a request with a HTTP [method], [url] and optional data.
|
||||
/// The [url] can be either a `String` or `Uri`.
|
||||
Future<Object?> send(String method, dynamic url, {dynamic json}) async {
|
||||
final uri = url is String ? Uri.parse(url) : url as Uri;
|
||||
|
||||
final request = Request(method, uri);
|
||||
if (credential != null) {
|
||||
request.headers['Authorization'] = 'Bearer $credential';
|
||||
}
|
||||
|
||||
if (json != null) {
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
request.body = jsonEncode(json);
|
||||
}
|
||||
|
||||
final streamedResponse = await _client.send(request);
|
||||
final response = await Response.fromStream(streamedResponse);
|
||||
|
||||
Object? bodyJson;
|
||||
try {
|
||||
bodyJson = jsonDecode(response.body);
|
||||
} on FormatException {
|
||||
final contentType = response.headers['content-type'];
|
||||
if (contentType != null && !contentType.contains('application/json')) {
|
||||
throw Exception(
|
||||
"Returned value was not JSON. Did the uri end with '.json'?");
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (bodyJson is Map) {
|
||||
final dynamic error = bodyJson['error'];
|
||||
if (error != null) {
|
||||
throw FirebaseClientException(response.statusCode, error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
throw FirebaseClientException(response.statusCode, bodyJson.toString());
|
||||
}
|
||||
|
||||
return bodyJson;
|
||||
}
|
||||
|
||||
/// Closes the client and cleans up any associated resources.
|
||||
void close() => _client.close();
|
||||
}
|
||||
|
||||
class FirebaseClientException implements Exception {
|
||||
FirebaseClientException(this.statusCode, this.message);
|
||||
|
||||
final int statusCode;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => '$message ($statusCode)';
|
||||
}
|
@ -1 +1,2 @@
|
||||
export 'cache_service.dart';
|
||||
export 'firebase_client.dart';
|
||||
|
@ -6,10 +6,21 @@ class LinkUtil {
|
||||
static final _browser = ChromeSafariBrowser();
|
||||
|
||||
static void launchUrl(String link, {bool useReader = false}) {
|
||||
String rinseLink(String link) {
|
||||
if (link.contains(')')) {
|
||||
final regex = RegExp(r'\).*$');
|
||||
final match = regex.stringMatch(link) ?? '';
|
||||
return link.replaceAll(match, '');
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
canLaunch(link).then((val) {
|
||||
if (val) {
|
||||
final rinsedLink = rinseLink(link);
|
||||
_browser.open(
|
||||
url: Uri.parse(link),
|
||||
url: Uri.parse(rinsedLink),
|
||||
options: ChromeSafariBrowserClassOptions(
|
||||
ios: IOSSafariOptions(
|
||||
entersReaderIfAvailable: useReader,
|
||||
|
18
pubspec.yaml
@ -1,6 +1,6 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 0.1.5+20
|
||||
version: 0.2.0+31
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
@ -8,9 +8,8 @@ environment:
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^2.3.0
|
||||
algolia: ^1.0.1
|
||||
badges: ^2.0.2
|
||||
bloc: ^7.0.0
|
||||
bloc: ^8.0.3
|
||||
cached_network_image: ^3.2.0
|
||||
clipboard: ^0.1.3
|
||||
collection:
|
||||
@ -18,12 +17,9 @@ dependencies:
|
||||
equatable: 2.0.3
|
||||
fast_gbk: ^1.0.0
|
||||
feature_discovery: ^0.14.0
|
||||
firebase_analytics: ^8.3.4
|
||||
firebase_core: ^1.6.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_app_badger: ^1.3.0
|
||||
flutter_bloc: ^7.1.0
|
||||
flutter_bloc: ^8.0.1
|
||||
flutter_fadein: ^2.0.0
|
||||
flutter_feather_icons: 2.0.0+1
|
||||
flutter_html: ^2.2.1
|
||||
@ -34,10 +30,10 @@ dependencies:
|
||||
font_awesome_flutter: ^9.2.0
|
||||
gbk_codec: ^0.4.0
|
||||
get_it: 7.2.0
|
||||
hive: ^2.0.6
|
||||
html: ^0.15.0
|
||||
html_unescape: ^2.0.0
|
||||
http: ^0.13.3
|
||||
in_app_review: ^2.0.3
|
||||
intl: ^0.17.0
|
||||
path: ^1.8.0
|
||||
path_provider: ^2.0.8
|
||||
@ -45,14 +41,16 @@ dependencies:
|
||||
sembast: ^3.1.1+1
|
||||
shared_preferences: ^2.0.11
|
||||
shimmer: ^2.0.0
|
||||
tuple: ^2.0.0
|
||||
universal_platform: ^1.0.0+1
|
||||
url_launcher: ^6.0.10
|
||||
wakelock: ^0.6.1+2
|
||||
|
||||
dev_dependencies:
|
||||
bloc_test: ^8.1.0
|
||||
bloc_test: ^9.0.3
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mocktail: ^0.1.0
|
||||
mocktail: ^0.3.0
|
||||
very_good_analysis: ^2.3.0
|
||||
|
||||
flutter:
|
||||
|