Compare commits

..

36 Commits

Author SHA1 Message Date
8348a87a75 Merge pull request #7 from Livinglist/v0.1.9
fixed notification screen.
2022-03-02 20:21:48 -08:00
58837b6c00 fixed notification screen. 2022-03-02 20:21:25 -08:00
8365869ee8 Merge pull request #6 from Livinglist/v0.1.9
v0.1.9
2022-03-02 16:41:45 -08:00
5c70185236 fixed ui. 2022-03-02 16:37:58 -08:00
e4a385deb7 fixed imports 2022-03-02 16:13:58 -08:00
dfde6a74eb fixed inconsistent font size. 2022-03-02 16:10:26 -08:00
a2223dc531 added the feature where tapping on comment in notification or history screen will lead user directly to the comment. 2022-03-02 16:03:33 -08:00
f75e6a5e3b updated README.md 2022-02-28 21:27:18 -08:00
a87d521d32 updated README.md 2022-02-28 17:23:28 -08:00
94d76d4c20 removed firebase package.
removed firebase package.
2022-02-28 15:50:46 -08:00
1176e3bb80 removed firebase package. 2022-02-28 15:46:23 -08:00
52b63efe1a Merge pull request #4 from Livinglist/v0.1.8
v0.1.8
2022-02-28 11:08:59 -08:00
9652c08a4f added delete cache func. 2022-02-28 00:06:10 -08:00
ddb437cd60 fixed msg. 2022-02-27 19:52:42 -08:00
1719036d18 added edit comment feature. 2022-02-27 19:31:35 -08:00
6451495297 fixed UI. 2022-02-27 19:05:52 -08:00
d0b6f19a80 added mark read stories feature. 2022-02-27 19:03:46 -08:00
a77eb889f1 updated fastlane. 2022-02-23 19:15:21 -08:00
b35ffa2921 added haptic feedback. 2022-02-23 19:13:08 -08:00
d0d031600c updated README.md 2022-02-23 18:59:52 -08:00
a8d3002f31 Merge pull request #3 from Livinglist/v0.1.7
v0.1.7
2022-02-23 18:58:18 -08:00
a35aa6ea3b fixed order of feature discovery. 2022-02-23 18:18:02 -08:00
b2d4369b57 fixed routing. 2022-02-23 18:05:08 -08:00
fa3b28d050 updated version. 2022-02-23 17:34:15 -08:00
746dd61f48 updated fastlane. 2022-02-23 17:31:31 -08:00
29165bdb09 added slidable to comment tile. 2022-02-23 17:20:42 -08:00
4b9de44297 removed comment border option. 2022-02-23 15:51:44 -08:00
9e48be158b added pin button to story screen. 2022-02-23 15:49:25 -08:00
e64ea5e99a disabled feature discovery resetting. 2022-02-23 15:32:16 -08:00
0fce662954 fixed discovery overlay behavior. 2022-02-23 15:31:38 -08:00
b9b9d5f99f fixed link parsing. 2022-02-23 15:26:54 -08:00
1583525b48 added fastlane. 2022-02-20 01:24:49 -08:00
4d94c85e81 fixed submit cubit. 2022-02-19 22:09:02 -08:00
d97dea258e added title length limit. 2022-02-19 11:24:36 -08:00
da898c37cc updated README.md 2022-02-19 10:31:02 -08:00
7e05aa825d added store badges. 2022-02-19 10:28:06 -08:00
58 changed files with 1171 additions and 485 deletions

View File

@ -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.
![iOS](https://img.shields.io/badge/iOS-13%20-blue) ![iOS](https://img.shields.io/badge/iOS-13%20-blue)
[![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763) [![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](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> <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: Features:
- Log in using your Hacker News account. - Log in using your Hacker News account.
@ -20,9 +22,9 @@ Features:
- Mark stories as favorite. - Mark stories as favorite.
- Browse comments and stories you have posted in the past. - Browse comments and stories you have posted in the past.
- Search for stories on Hacker News. - Search for stories on Hacker News.
- Double tap to collapse a comment. - Collapse comments.
- Long press to vote on a comment or story. - Vote on comments or stories.
- Swipe to right to pin a story to top. - Pin stories to the top of home page.
- Get in-app notification when there is new reply to your stories or comments. - Get in-app notification when there is new reply to your stories or comments.
- And more... - And more...
@ -32,7 +34,7 @@ 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/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/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/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/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/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/152305175-94fa3696-f40f-4f40-b040-f17fc59ff260.png">
@ -42,4 +44,3 @@ Features:
<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/153973715-a33018d2-d3b1-4bfa-be39-56f5e3c4830b.png">
</p> </p>

View File

@ -0,0 +1 @@
- Bugfixes.

View File

@ -0,0 +1 @@
- Updates to UI.

View File

@ -0,0 +1 @@
- Updates to UI.

View File

@ -0,0 +1 @@
- Updates to UI.

View File

@ -0,0 +1 @@
- Tapping on comments in notification and history screen will lead you directly to the comment.

View File

@ -0,0 +1,13 @@
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.
- And more...

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

View File

@ -0,0 +1 @@
Hacki is a simple noiseless Hacker News reader.

View File

@ -0,0 +1 @@
Hacki for Hacker News

BIN
images/app_store_badge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -365,7 +365,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1.5; MARKETING_VERSION = 0.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -500,7 +500,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1.5; MARKETING_VERSION = 0.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -529,7 +529,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1.5; MARKETING_VERSION = 0.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -11,4 +11,5 @@ class Constants {
static const String featureAddStoryToFavList = 'add_story_to_fav_list'; static const String featureAddStoryToFavList = 'add_story_to_fav_list';
static const String featureOpenStoryInWebView = 'open_story_in_web_view'; static const String featureOpenStoryInWebView = 'open_story_in_web_view';
static const String featureLogIn = 'log_in'; static const String featureLogIn = 'log_in';
static const String featurePinToTop = 'pin_to_top';
} }

View File

@ -14,5 +14,6 @@ Future<void> setUpLocator() async {
..registerSingleton<AuthRepository>(AuthRepository()) ..registerSingleton<AuthRepository>(AuthRepository())
..registerSingleton<PostRepository>(PostRepository()) ..registerSingleton<PostRepository>(PostRepository())
..registerSingleton<SembastRepository>(SembastRepository()) ..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<CacheRepository>(CacheRepository())
..registerSingleton<CacheService>(CacheService()); ..registerSingleton<CacheService>(CacheService());
} }

32
lib/cubits/cache/cache_cubit.dart vendored Normal file
View 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.cacheReadStory(id: id);
}
void deleteAll() {
emit(CacheState.init());
_cacheRepository.deleteAll();
}
}

30
lib/cubits/cache/cache_state.dart vendored Normal file
View 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];
}

View File

@ -8,21 +8,31 @@ import 'package:hacki/services/cache_service.dart';
part 'comments_state.dart'; part 'comments_state.dart';
class CommentsCubit<T extends Item> extends Cubit<CommentsState> { class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
CommentsCubit( CommentsCubit({
{required T item, CacheService? cacheService,
CacheService? cacheService, StoriesRepository? storiesRepository,
StoriesRepository? storiesRepository}) }) : _cacheService = cacheService ?? locator.get<CacheService>(),
: _cacheService = cacheService ?? locator.get<CacheService>(),
_storiesRepository = _storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(), storiesRepository ?? locator.get<StoriesRepository>(),
super(CommentsState.init()) { super(CommentsState.init());
init(item);
}
final CacheService _cacheService; final CacheService _cacheService;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
Future<void> init(T item) async { Future<void> init(
T item, {
bool onlyShowTargetComment = false,
Comment? targetComment,
}) async {
if (onlyShowTargetComment) {
emit(state.copyWith(
item: item,
comments: targetComment != null ? [targetComment] : [],
onlyShowTargetComment: true,
));
return;
}
if (item is Story) { if (item is Story) {
final story = item; final story = item;
final updatedStory = await _storiesRepository.fetchStoryById(story.id); final updatedStory = await _storiesRepository.fetchStoryById(story.id);
@ -97,6 +107,14 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
emit(state.copyWith(collapsed: !state.collapsed)); emit(state.copyWith(collapsed: !state.collapsed));
} }
void loadAll(T item) {
emit(state.copyWith(
onlyShowTargetComment: false,
comments: [],
));
init(item);
}
void _onCommentFetched(Comment? comment) { void _onCommentFetched(Comment? comment) {
if (comment != null) { if (comment != null) {
_cacheService.cacheComment(comment); _cacheService.cacheComment(comment);

View File

@ -13,30 +13,36 @@ class CommentsState extends Equatable {
required this.comments, required this.comments,
required this.status, required this.status,
required this.collapsed, required this.collapsed,
required this.onlyShowTargetComment,
}); });
CommentsState.init() CommentsState.init()
: item = null, : item = null,
comments = [], comments = [],
status = CommentsStatus.init, status = CommentsStatus.init,
collapsed = false; collapsed = false,
onlyShowTargetComment = false;
final Item? item; final Item? item;
final List<Comment> comments; final List<Comment> comments;
final CommentsStatus status; final CommentsStatus status;
final bool collapsed; final bool collapsed;
final bool onlyShowTargetComment;
CommentsState copyWith({ CommentsState copyWith({
Item? item, Item? item,
List<Comment>? comments, List<Comment>? comments,
CommentsStatus? status, CommentsStatus? status,
bool? collapsed, bool? collapsed,
bool? onlyShowTargetComment,
}) { }) {
return CommentsState( return CommentsState(
item: item ?? this.item, item: item ?? this.item,
comments: comments ?? this.comments, comments: comments ?? this.comments,
status: status ?? this.status, status: status ?? this.status,
collapsed: collapsed ?? this.collapsed, collapsed: collapsed ?? this.collapsed,
onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment,
); );
} }
@ -46,5 +52,6 @@ class CommentsState extends Equatable {
comments, comments,
status, status,
collapsed, collapsed,
onlyShowTargetComment,
]; ];
} }

View File

@ -1,4 +1,5 @@
export 'blocklist/blocklist_cubit.dart'; export 'blocklist/blocklist_cubit.dart';
export 'cache/cache_cubit.dart';
export 'comments/comments_cubit.dart'; export 'comments/comments_cubit.dart';
export 'edit/edit_cubit.dart'; export 'edit/edit_cubit.dart';
export 'fav/fav_cubit.dart'; export 'fav/fav_cubit.dart';

View File

@ -16,13 +16,20 @@ class EditCubit extends Cubit<EditState> {
final CacheService _cacheService; final CacheService _cacheService;
final Debouncer _debouncer; final Debouncer _debouncer;
void onItemTapped(Item item) { void onReplyTapped(Item item) {
emit(EditState( emit(EditState(
replyingTo: item, replyingTo: item,
text: _cacheService.getDraft(replyingTo: item.id), text: _cacheService.getDraft(replyingTo: item.id),
)); ));
} }
void onEditTapped(Item itemToBeEdited) {
emit(EditState(
itemBeingEdited: itemToBeEdited,
text: itemToBeEdited.text,
));
}
void onReplyBoxClosed() { void onReplyBoxClosed() {
emit(const EditState.init()); emit(const EditState.init());
} }

View File

@ -4,20 +4,24 @@ class EditState extends Equatable {
const EditState({ const EditState({
this.text, this.text,
this.replyingTo, this.replyingTo,
this.itemBeingEdited,
}); });
const EditState.init() const EditState.init()
: text = null, : text = null,
replyingTo = null; replyingTo = null,
itemBeingEdited = null;
final String? text; final String? text;
final Item? replyingTo; final Item? replyingTo;
final Item? itemBeingEdited;
bool get showReplyBox => replyingTo != null; bool get showReplyBox => replyingTo != null || itemBeingEdited != null;
EditState copyWith({String? text}) { EditState copyWith({String? text}) {
return EditState( return EditState(
replyingTo: replyingTo, replyingTo: replyingTo,
itemBeingEdited: itemBeingEdited,
text: text ?? this.text, text: text ?? this.text,
); );
} }
@ -26,5 +30,6 @@ class EditState extends Equatable {
List<Object?> get props => [ List<Object?> get props => [
text, text,
replyingTo, replyingTo,
itemBeingEdited,
]; ];
} }

View File

@ -3,7 +3,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.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/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';

View File

@ -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() { void reset() {
emit(state.copyWith(status: PostStatus.init)); emit(state.copyWith(status: PostStatus.init));
} }

View File

@ -22,14 +22,14 @@ class PreferenceCubit extends Cubit<PreferenceState> {
.then((value) => emit(state.copyWith(showComplexStoryTile: value))); .then((value) => emit(state.copyWith(showComplexStoryTile: value)));
_storageRepository.shouldShowWebFirst _storageRepository.shouldShowWebFirst
.then((value) => emit(state.copyWith(showWebFirst: value))); .then((value) => emit(state.copyWith(showWebFirst: value)));
_storageRepository.shouldCommentBorder
.then((value) => emit(state.copyWith(showCommentBorder: value)));
_storageRepository.shouldShowEyeCandy _storageRepository.shouldShowEyeCandy
.then((value) => emit(state.copyWith(showEyeCandy: value))); .then((value) => emit(state.copyWith(showEyeCandy: value)));
_storageRepository.trueDarkMode _storageRepository.trueDarkMode
.then((value) => emit(state.copyWith(useTrueDark: value))); .then((value) => emit(state.copyWith(useTrueDark: value)));
_storageRepository.readerMode _storageRepository.readerMode
.then((value) => emit(state.copyWith(useReader: value))); .then((value) => emit(state.copyWith(useReader: value)));
_storageRepository.markReadStories
.then((value) => emit(state.copyWith(markReadStories: value)));
} }
void toggleNotificationMode() { void toggleNotificationMode() {
@ -47,11 +47,6 @@ class PreferenceCubit extends Cubit<PreferenceState> {
_storageRepository.toggleNavigationMode(); _storageRepository.toggleNavigationMode();
} }
void toggleCommentBorderMode() {
emit(state.copyWith(showCommentBorder: !state.showCommentBorder));
_storageRepository.toggleCommentBorderMode();
}
void toggleEyeCandyMode() { void toggleEyeCandyMode() {
emit(state.copyWith(showEyeCandy: !state.showEyeCandy)); emit(state.copyWith(showEyeCandy: !state.showEyeCandy));
_storageRepository.toggleEyeCandyMode(); _storageRepository.toggleEyeCandyMode();
@ -66,4 +61,9 @@ class PreferenceCubit extends Cubit<PreferenceState> {
emit(state.copyWith(useReader: !state.useReader)); emit(state.copyWith(useReader: !state.useReader));
_storageRepository.toggleReaderMode(); _storageRepository.toggleReaderMode();
} }
void toggleMarkReadStoriesMode() {
emit(state.copyWith(markReadStories: !state.markReadStories));
_storageRepository.toggleMarkReadStoriesMode();
}
} }

View File

@ -5,46 +5,46 @@ class PreferenceState extends Equatable {
required this.showNotification, required this.showNotification,
required this.showComplexStoryTile, required this.showComplexStoryTile,
required this.showWebFirst, required this.showWebFirst,
required this.showCommentBorder,
required this.showEyeCandy, required this.showEyeCandy,
required this.useTrueDark, required this.useTrueDark,
required this.useReader, required this.useReader,
required this.markReadStories,
}); });
const PreferenceState.init() const PreferenceState.init()
: showNotification = false, : showNotification = false,
showComplexStoryTile = false, showComplexStoryTile = false,
showWebFirst = false, showWebFirst = false,
showCommentBorder = false,
showEyeCandy = false, showEyeCandy = false,
useTrueDark = false, useTrueDark = false,
useReader = false; useReader = false,
markReadStories = false;
final bool showNotification; final bool showNotification;
final bool showComplexStoryTile; final bool showComplexStoryTile;
final bool showWebFirst; final bool showWebFirst;
final bool showCommentBorder;
final bool showEyeCandy; final bool showEyeCandy;
final bool useTrueDark; final bool useTrueDark;
final bool useReader; final bool useReader;
final bool markReadStories;
PreferenceState copyWith({ PreferenceState copyWith({
bool? showNotification, bool? showNotification,
bool? showComplexStoryTile, bool? showComplexStoryTile,
bool? showWebFirst, bool? showWebFirst,
bool? showCommentBorder,
bool? showEyeCandy, bool? showEyeCandy,
bool? useTrueDark, bool? useTrueDark,
bool? useReader, bool? useReader,
bool? markReadStories,
}) { }) {
return PreferenceState( return PreferenceState(
showNotification: showNotification ?? this.showNotification, showNotification: showNotification ?? this.showNotification,
showComplexStoryTile: showComplexStoryTile ?? this.showComplexStoryTile, showComplexStoryTile: showComplexStoryTile ?? this.showComplexStoryTile,
showWebFirst: showWebFirst ?? this.showWebFirst, showWebFirst: showWebFirst ?? this.showWebFirst,
showCommentBorder: showCommentBorder ?? this.showCommentBorder,
showEyeCandy: showEyeCandy ?? this.showEyeCandy, showEyeCandy: showEyeCandy ?? this.showEyeCandy,
useTrueDark: useTrueDark ?? this.useTrueDark, useTrueDark: useTrueDark ?? this.useTrueDark,
useReader: useReader ?? this.useReader, useReader: useReader ?? this.useReader,
markReadStories: markReadStories ?? this.markReadStories,
); );
} }
@ -53,9 +53,9 @@ class PreferenceState extends Equatable {
showNotification, showNotification,
showComplexStoryTile, showComplexStoryTile,
showWebFirst, showWebFirst,
showCommentBorder,
showEyeCandy, showEyeCandy,
useTrueDark, useTrueDark,
useReader, useReader,
markReadStories,
]; ];
} }

View File

@ -35,11 +35,9 @@ class SubmitCubit extends Cubit<SubmitState> {
text: state.text, text: state.text,
) )
.then((successful) { .then((successful) {
if (successful) { emit(state.copyWith(status: SubmitStatus.submitted));
emit(state.copyWith(status: SubmitStatus.submitted)); }).onError((error, stackTrace) {
} else { emit(state.copyWith(status: SubmitStatus.failure));
emit(state.copyWith(status: SubmitStatus.failure));
}
}); });
} }
} }

View File

@ -7,10 +7,16 @@ import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hive/hive.dart';
import 'package:path_provider/path_provider.dart';
Future main() async { Future main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final tempDir = await getTemporaryDirectory();
final tempPath = tempDir.path;
Hive.init(tempPath);
await setUpLocator(); await setUpLocator();
final savedThemeMode = await AdaptiveTheme.getThemeMode(); final savedThemeMode = await AdaptiveTheme.getThemeMode();
@ -77,6 +83,10 @@ class HackiApp extends StatelessWidget {
lazy: false, lazy: false,
create: (context) => PinCubit(), create: (context) => PinCubit(),
), ),
BlocProvider<CacheCubit>(
lazy: false,
create: (context) => CacheCubit(),
),
], ],
child: AdaptiveTheme( child: AdaptiveTheme(
light: ThemeData( light: ThemeData(

View File

@ -6,6 +6,7 @@ class Comment extends Item {
required int id, required int id,
required int time, required int time,
required int parent, required int parent,
required int score,
required String by, required String by,
required String text, required String text,
required List<int> kids, required List<int> kids,
@ -18,7 +19,7 @@ class Comment extends Item {
kids: kids, kids: kids,
parent: parent, parent: parent,
deleted: deleted, deleted: deleted,
score: 0, score: score,
descendants: 0, descendants: 0,
dead: false, dead: false,
parts: [], parts: [],
@ -37,7 +38,7 @@ class Comment extends Item {
kids: (json['kids'] as List?)?.cast<int>() ?? [], kids: (json['kids'] as List?)?.cast<int>() ?? [],
parent: json['parent'] as int? ?? 0, parent: json['parent'] as int? ?? 0,
deleted: json['deleted'] as bool? ?? false, deleted: json['deleted'] as bool? ?? false,
score: 0, score: json['score'] as int? ?? 0,
descendants: 0, descendants: 0,
dead: json['dead'] as bool? ?? false, dead: json['dead'] as bool? ?? false,
parts: [], parts: [],
@ -59,6 +60,7 @@ class Comment extends Item {
'parent': parent, 'parent': parent,
'deleted': deleted, 'deleted': deleted,
'dead': dead, 'dead': dead,
'score': score,
}; };
@override @override

View File

@ -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 { class FormPostData with PostDataMixin {
FormPostData({ FormPostData({
required this.acct, required this.acct,

View File

@ -0,0 +1,31 @@
import 'package:hive/hive.dart';
class CacheRepository {
CacheRepository({Future<Box<bool>>? box})
: _box = box ?? Hive.openBox<bool>(_boxName);
static const _boxName = 'cacheBox';
final Future<Box<bool>> _box;
Future<bool> wasRead({required int id}) async {
final box = await _box;
final val = box.get(id.toString());
return val != null;
}
Future<void> cacheReadStory({required int id}) async {
final box = await _box;
return box.put(id.toString(), true);
}
Future<List<int>> getAllReadStoriesIds() async {
final box = await _box;
final allReads = box.keys.cast<String>().map(int.parse).toList();
return allReads;
}
Future<int> deleteAll() async {
final box = await _box;
return box.clear();
}
}

View File

@ -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({ Future<Response<List<int>>> _getFormResponse({
required String username, required String username,
required String password, required String password,

View File

@ -1,4 +1,5 @@
export 'auth_repository.dart'; export 'auth_repository.dart';
export 'cache_repository.dart';
export 'post_repository.dart'; export 'post_repository.dart';
export 'search_repository.dart'; export 'search_repository.dart';
export 'sembast_repository.dart'; export 'sembast_repository.dart';

View File

@ -12,6 +12,8 @@ class StorageRepository {
static const String _passwordKey = 'password'; static const String _passwordKey = 'password';
static const String _blocklistKey = 'blocklist'; static const String _blocklistKey = 'blocklist';
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds'; static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
static const String _notificationModeKey = 'notificationMode'; static const String _notificationModeKey = 'notificationMode';
static const String _trueDarkModeKey = 'trueDarkMode'; static const String _trueDarkModeKey = 'trueDarkMode';
static const String _readerModeKey = 'readerMode'; static const String _readerModeKey = 'readerMode';
@ -23,18 +25,16 @@ class StorageRepository {
/// The key of a boolean value deciding whether or not user should be /// The key of a boolean value deciding whether or not user should be
/// navigated to web view first. Defaults to false. /// navigated to web view first. Defaults to false.
static const String _navigationModeKey = 'navigationMode'; static const String _navigationModeKey = 'navigationMode';
static const String _commentBorderModeKey = 'commentBorderMode';
static const String _eyeCandyModeKey = 'eyeCandyMode'; static const String _eyeCandyModeKey = 'eyeCandyMode';
static const String _unreadCommentsIdsKey = 'unreadCommentsIds'; static const String _markReadStoriesModeKey = 'markReadStoriesMode';
static const bool _notificationModeDefaultValue = true; static const bool _notificationModeDefaultValue = true;
static const bool _displayModeDefaultValue = true; static const bool _displayModeDefaultValue = true;
static const bool _navigationModeDefaultValue = true; static const bool _navigationModeDefaultValue = true;
static const bool _commentBorderModeDefaultValue = true;
static const bool _eyeCandyModeDefaultValue = false; static const bool _eyeCandyModeDefaultValue = false;
static const bool _trueDarkModeDefaultValue = 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 Future<SharedPreferences> _prefs;
final FlutterSecureStorage _secureStorage; final FlutterSecureStorage _secureStorage;
@ -60,9 +60,6 @@ class StorageRepository {
Future<bool> get shouldShowWebFirst async => _prefs.then((prefs) => Future<bool> get shouldShowWebFirst async => _prefs.then((prefs) =>
prefs.getBool(_navigationModeKey) ?? _navigationModeDefaultValue); prefs.getBool(_navigationModeKey) ?? _navigationModeDefaultValue);
Future<bool> get shouldCommentBorder async => _prefs.then((prefs) =>
prefs.getBool(_commentBorderModeKey) ?? _commentBorderModeDefaultValue);
Future<bool> get shouldShowEyeCandy async => _prefs.then( Future<bool> get shouldShowEyeCandy async => _prefs.then(
(prefs) => prefs.getBool(_eyeCandyModeKey) ?? _eyeCandyModeDefaultValue); (prefs) => prefs.getBool(_eyeCandyModeKey) ?? _eyeCandyModeDefaultValue);
@ -70,7 +67,11 @@ class StorageRepository {
(prefs) => prefs.getBool(_trueDarkModeKey) ?? _trueDarkModeDefaultValue); (prefs) => prefs.getBool(_trueDarkModeKey) ?? _trueDarkModeDefaultValue);
Future<bool> get readerMode async => _prefs.then( 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) => Future<List<int>> get unreadCommentsIds async => _prefs.then((prefs) =>
prefs.getStringList(_unreadCommentsIdsKey)?.map(int.parse).toList() ?? prefs.getStringList(_unreadCommentsIdsKey)?.map(int.parse).toList() ??
@ -124,13 +125,6 @@ class StorageRepository {
await prefs.setBool(_navigationModeKey, !currentMode); 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 { Future<void> toggleEyeCandyMode() async {
final prefs = await _prefs; final prefs = await _prefs;
final currentMode = final currentMode =
@ -148,10 +142,17 @@ class StorageRepository {
Future<void> toggleReaderMode() async { Future<void> toggleReaderMode() async {
final prefs = await _prefs; final prefs = await _prefs;
final currentMode = final currentMode =
prefs.getBool(_readerModeKey) ?? _readerModeKeyDefaultValue; prefs.getBool(_readerModeKey) ?? _readerModeDefaultValue;
await prefs.setBool(_readerModeKey, !currentMode); 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 { Future<void> addFav({required String username, required int id}) async {
final prefs = await _prefs; final prefs = await _prefs;
final key = _getFavKey(username); final key = _getFavKey(username);

View File

@ -1,7 +1,8 @@
import 'package:firebase/firebase_io.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:html_unescape/html_unescape.dart'; import 'package:html_unescape/html_unescape.dart';
import 'package:tuple/tuple.dart';
class StoriesRepository { class StoriesRepository {
StoriesRepository({ StoriesRepository({
@ -128,7 +129,7 @@ class StoriesRepository {
Future<Item?> fetchItemBy({required int id}) async { Future<Item?> fetchItemBy({required int id}) async {
final item = await _firebaseClient final item = await _firebaseClient
.get('${_baseUrl}item/$id.json') .get('${_baseUrl}item/$id.json')
.then((dynamic val) { .then((dynamic val) async {
if (val == null) { if (val == null) {
return null; return null;
} }
@ -138,6 +139,9 @@ class StoriesRepository {
final story = Story.fromJson(json); final story = Story.fromJson(json);
return story; return story;
} else if (json['type'] == 'comment') { } 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); final comment = Comment.fromJson(json);
return comment; return comment;
} }
@ -172,6 +176,22 @@ class StoriesRepository {
return item as Story; 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);
}
static String _parseHtml(String text) { static String _parseHtml(String text) {
return HtmlUnescape() return HtmlUnescape()
.convert(text) .convert(text)

View File

@ -1,3 +1,5 @@
// ignore_for_file: lines_longer_than_80_chars
import 'package:badges/badges.dart'; import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -21,7 +23,7 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key); const HomeScreen({Key? key}) : super(key: key);
static const String routeName = '/home'; static const String routeName = '/';
static Route route() { static Route route() {
return MaterialPageRoute<HomeScreen>( return MaterialPageRoute<HomeScreen>(
@ -54,6 +56,7 @@ class _HomeScreenState extends State<HomeScreen>
// Constants.featureLogIn, // Constants.featureLogIn,
// Constants.featureAddStoryToFavList, // Constants.featureAddStoryToFavList,
// Constants.featureOpenStoryInWebView, // Constants.featureOpenStoryInWebView,
// Constants.featurePinToTop,
// ]); // ]);
SchedulerBinding.instance?.addPostFrameCallback((_) { SchedulerBinding.instance?.addPostFrameCallback((_) {
@ -76,6 +79,8 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>( return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (previous, current) =>
previous.showComplexStoryTile != current.showComplexStoryTile,
builder: (context, preferenceState) { builder: (context, preferenceState) {
final pinnedStories = BlocBuilder<PinCubit, PinState>( final pinnedStories = BlocBuilder<PinCubit, PinState>(
builder: (context, state) { builder: (context, state) {
@ -88,8 +93,10 @@ class _HomeScreenState extends State<HomeScreen>
motion: const BehindMotion(), motion: const BehindMotion(),
children: [ children: [
SlidableAction( SlidableAction(
onPressed: (_) => onPressed: (_) {
context.read<PinCubit>().unpinStory(story), HapticFeedback.lightImpact();
context.read<PinCubit>().unpinStory(story);
},
backgroundColor: Colors.red, backgroundColor: Colors.red,
foregroundColor: Colors.white, foregroundColor: Colors.white,
icon: preferenceState.showComplexStoryTile icon: preferenceState.showComplexStoryTile
@ -151,248 +158,269 @@ class _HomeScreenState extends State<HomeScreen>
} }
}, },
builder: (context, state) { builder: (context, state) {
return WillPopScope( return BlocBuilder<CacheCubit, CacheState>(
onWillPop: () => Future.value(false), builder: (context, cacheState) {
child: DefaultTabController( return DefaultTabController(
length: 6, length: 6,
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size(0, 48), preferredSize: const Size(0, 48),
child: Column( child: Column(
children: [ children: [
SizedBox( SizedBox(
height: MediaQuery.of(context).padding.top, height: MediaQuery.of(context).padding.top,
), ),
TabBar( TabBar(
isScrollable: true, isScrollable: true,
controller: tabController, controller: tabController,
indicatorColor: Colors.orange, indicatorColor: Colors.orange,
tabs: [ tabs: [
Tab( Tab(
child: Text( child: Text(
'TOP', 'TOP',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: currentIndex == 0 color: currentIndex == 0
? Colors.orange ? Colors.orange
: Colors.grey, : Colors.grey,
),
), ),
), ),
), Tab(
Tab( child: Text(
child: Text( 'NEW',
'NEW', style: TextStyle(
style: TextStyle( fontSize: 14,
fontSize: 14, color: currentIndex == 1
color: currentIndex == 1 ? Colors.orange
? Colors.orange : Colors.grey,
: Colors.grey, ),
), ),
), ),
), Tab(
Tab( child: Text(
child: Text( 'ASK',
'ASK', style: TextStyle(
style: TextStyle( fontSize: 14,
fontSize: 14, color: currentIndex == 2
color: currentIndex == 2 ? Colors.orange
? Colors.orange : Colors.grey,
: Colors.grey, ),
), ),
), ),
), Tab(
Tab( child: Text(
child: Text( 'SHOW',
'SHOW', style: TextStyle(
style: TextStyle( fontSize: 13,
fontSize: 13, color: currentIndex == 3
color: currentIndex == 3 ? Colors.orange
? Colors.orange : Colors.grey,
: Colors.grey, ),
), ),
), ),
), Tab(
Tab( child: Text(
child: Text( 'JOBS',
'JOBS', style: TextStyle(
style: TextStyle( fontSize: 14,
fontSize: 14, color: currentIndex == 4
color: currentIndex == 4 ? Colors.orange
? Colors.orange : Colors.grey,
: Colors.grey, ),
), ),
), ),
), Tab(
Tab( child: DescribedFeatureOverlay(
child: DescribedFeatureOverlay( barrierDismissible: false,
targetColor: Theme.of(context).primaryColor, overflowMode: OverflowMode.extendBackground,
tapTarget: const Icon( targetColor: Theme.of(context).primaryColor,
Icons.person, tapTarget: const Icon(
size: 16, Icons.person,
color: Colors.white, size: 16,
), color: Colors.white,
featureId: Constants.featureLogIn, ),
title: const Text(''), featureId: Constants.featureLogIn,
description: const Text( title: const Text(''),
'Log in using your Hacker News account ' description: const Text(
'to check out stories and comments you have ' 'Log in using your Hacker News account '
'posted in the past, and get in-app ' 'to check out stories and comments you have '
'notification when there is new reply to ' 'posted in the past, and get in-app '
'your comments or stories.\n\nAlso, you can ' 'notification when there is new reply to '
'long press here to submit a new link to ' 'your comments or stories.',
'Hacker News.', style: TextStyle(fontSize: 16),
style: TextStyle(fontSize: 16), ),
), child: BlocBuilder<NotificationCubit,
child: BlocBuilder<NotificationCubit, NotificationState>(
NotificationState>( builder: (context, state) {
builder: (context, state) { if (state.unreadCommentsIds.isEmpty) {
if (state.unreadCommentsIds.isEmpty) { return Icon(
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, Icons.person,
size: 16, size: 16,
color: currentIndex == 5 color: currentIndex == 5
? Colors.orange ? Colors.orange
: Colors.grey, : 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,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
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,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
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,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
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,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
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,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
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: [
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(),
],
),
),
),
); );
}, },
); );
@ -418,5 +446,7 @@ class _HomeScreenState extends State<HomeScreen>
LinkUtil.launchUrl(story.url, useReader: useReader); LinkUtil.launchUrl(story.url, useReader: useReader);
cacheService.store(story.id); cacheService.store(story.id);
} }
context.read<CacheCubit>().markStoryAsRead(story.id);
} }
} }

View File

@ -19,7 +19,7 @@ import 'package:hacki/utils/utils.dart';
import 'package:in_app_review/in_app_review.dart'; import 'package:in_app_review/in_app_review.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart';
enum PageType { enum _PageType {
fav, fav,
history, history,
settings, settings,
@ -40,8 +40,9 @@ class _ProfileScreenState extends State<ProfileScreen>
final refreshControllerFav = RefreshController(); final refreshControllerFav = RefreshController();
final refreshControllerNotification = RefreshController(); final refreshControllerNotification = RefreshController();
final scrollController = ScrollController(); final scrollController = ScrollController();
final throttle = Throttle(delay: const Duration(seconds: 2));
PageType pageType = PageType.notification; _PageType pageType = _PageType.notification;
final magicWords = <String>[ final magicWords = <String>[
'to be a lord.', 'to be a lord.',
@ -52,6 +53,16 @@ class _ProfileScreenState extends State<ProfileScreen>
'to infinity and beyond!', 'to infinity and beyond!',
]; ];
@override
void dispose() {
super.dispose();
refreshControllerHistory.dispose();
refreshControllerFav.dispose();
refreshControllerNotification.dispose();
scrollController.dispose();
throttle.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
@ -61,6 +72,8 @@ class _ProfileScreenState extends State<ProfileScreen>
return BlocBuilder<AuthBloc, AuthState>( return BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) { builder: (context, authState) {
return BlocConsumer<NotificationCubit, NotificationState>( return BlocConsumer<NotificationCubit, NotificationState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, notificationState) { listener: (context, notificationState) {
if (notificationState.status == NotificationStatus.loaded) { if (notificationState.status == NotificationStatus.loaded) {
refreshControllerNotification refreshControllerNotification
@ -71,30 +84,10 @@ class _ProfileScreenState extends State<ProfileScreen>
builder: (context, notificationState) { builder: (context, notificationState) {
return Stack( return Stack(
children: [ 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( Positioned.fill(
top: 50, top: 50,
child: Offstage( child: Offstage(
offstage: !authState.isLoggedIn || offstage: pageType != _PageType.history,
pageType != PageType.history,
child: BlocConsumer<HistoryCubit, HistoryState>( child: BlocConsumer<HistoryCubit, HistoryState>(
listener: (context, historyState) { listener: (context, historyState) {
if (historyState.status == HistoryStatus.loaded) { if (historyState.status == HistoryStatus.loaded) {
@ -104,8 +97,18 @@ class _ProfileScreenState extends State<ProfileScreen>
} }
}, },
builder: (context, historyState) { 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>( return ItemsListView<Item>(
showWebPreview: false, showWebPreview: false,
useConsistentFontSize: true,
refreshController: refreshControllerHistory, refreshController: refreshControllerHistory,
items: historyState.submittedItems items: historyState.submittedItems
.where((e) => !e.dead && !e.deleted) .where((e) => !e.dead && !e.deleted)
@ -123,17 +126,7 @@ class _ProfileScreenState extends State<ProfileScreen>
StoryScreen.routeName, StoryScreen.routeName,
arguments: StoryScreenArgs(story: item)); arguments: StoryScreenArgs(story: item));
} else if (item is Comment) { } else if (item is Comment) {
locator onCommentTapped(item);
.get<StoriesRepository>()
.fetchParentStory(id: item.parent)
.then((story) {
if (story != null && mounted) {
HackiApp.navigatorKey.currentState!
.pushNamed(StoryScreen.routeName,
arguments: StoryScreenArgs(
story: story));
}
});
} }
}, },
); );
@ -144,7 +137,7 @@ class _ProfileScreenState extends State<ProfileScreen>
Positioned.fill( Positioned.fill(
top: 50, top: 50,
child: Offstage( child: Offstage(
offstage: pageType != PageType.fav, offstage: pageType != _PageType.fav,
child: BlocConsumer<FavCubit, FavState>( child: BlocConsumer<FavCubit, FavState>(
listener: (context, favState) { listener: (context, favState) {
if (favState.status == FavStatus.loaded) { if (favState.status == FavStatus.loaded) {
@ -154,6 +147,15 @@ class _ProfileScreenState extends State<ProfileScreen>
} }
}, },
builder: (context, favState) { 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>( return ItemsListView<Story>(
showWebPreview: showWebPreview:
preferenceState.showComplexStoryTile, preferenceState.showComplexStoryTile,
@ -179,35 +181,22 @@ class _ProfileScreenState extends State<ProfileScreen>
Positioned.fill( Positioned.fill(
top: 50, top: 50,
child: Offstage( child: Offstage(
offstage: pageType != PageType.search, offstage: pageType != _PageType.search,
child: const SearchScreen(), child: const SearchScreen(),
), ),
), ),
Positioned.fill( Positioned.fill(
top: 50, top: 50,
child: Offstage( child: Offstage(
offstage: pageType != PageType.notification, offstage: pageType != _PageType.notification,
child: InboxView( child: InboxView(
refreshController: refreshControllerNotification, refreshController: refreshControllerNotification,
unreadCommentsIds: unreadCommentsIds:
notificationState.unreadCommentsIds, notificationState.unreadCommentsIds,
comments: notificationState.comments, comments: notificationState.comments,
onCommentTapped: (comment) { onCommentTapped: (cmt) {
locator onCommentTapped(cmt, then: () {
.get<StoriesRepository>() context.read<NotificationCubit>().markAsRead(cmt);
.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,
),
);
}
}); });
}, },
onMarkAllAsReadTapped: () { onMarkAllAsReadTapped: () {
@ -226,7 +215,7 @@ class _ProfileScreenState extends State<ProfileScreen>
Positioned.fill( Positioned.fill(
top: 50, top: 50,
child: Offstage( child: Offstage(
offstage: pageType != PageType.settings, offstage: pageType != _PageType.settings,
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
@ -307,14 +296,20 @@ class _ProfileScreenState extends State<ProfileScreen>
activeColor: Colors.orange, activeColor: Colors.orange,
), ),
SwitchListTile( SwitchListTile(
title: const Text('Show Comment Outlines'), title: const Text('Mark Read Stories'),
subtitle: const Text('be nice to your eyes.'), subtitle: const Text(
value: preferenceState.showCommentBorder, 'grey out stories you have read.'),
value: preferenceState.markReadStories,
onChanged: (val) { onChanged: (val) {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
if (!val) {
context.read<CacheCubit>().deleteAll();
}
context context
.read<PreferenceCubit>() .read<PreferenceCubit>()
.toggleCommentBorderMode(); .toggleMarkReadStoriesMode();
}, },
activeColor: Colors.orange, activeColor: Colors.orange,
), ),
@ -377,7 +372,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: 'Hacki', applicationName: 'Hacki',
applicationVersion: 'v0.1.5', applicationVersion: 'v0.1.9',
applicationIcon: Image.asset( applicationIcon: Image.asset(
Constants.hackiIconPath, Constants.hackiIconPath,
height: 50, height: 50,
@ -465,13 +460,13 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
CustomChip( CustomChip(
label: 'Inbox : ' label: 'Inbox : '
//ignore: lines_longer_than_80_chars // ignore: lines_longer_than_80_chars
'${notificationState.unreadCommentsIds.length}', '${notificationState.unreadCommentsIds.length}',
selected: pageType == PageType.notification, selected: pageType == _PageType.notification,
onSelected: (val) { onSelected: (val) {
if (val) { if (val) {
setState(() { setState(() {
pageType = PageType.notification; pageType = _PageType.notification;
}); });
} }
}, },
@ -481,11 +476,11 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
CustomChip( CustomChip(
label: 'Favorite', label: 'Favorite',
selected: pageType == PageType.fav, selected: pageType == _PageType.fav,
onSelected: (val) { onSelected: (val) {
if (val) { if (val) {
setState(() { setState(() {
pageType = PageType.fav; pageType = _PageType.fav;
}); });
} }
}, },
@ -495,11 +490,11 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
CustomChip( CustomChip(
label: 'Submitted', label: 'Submitted',
selected: pageType == PageType.history, selected: pageType == _PageType.history,
onSelected: (val) { onSelected: (val) {
if (val) { if (val) {
setState(() { setState(() {
pageType = PageType.history; pageType = _PageType.history;
}); });
} }
}, },
@ -509,11 +504,11 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
CustomChip( CustomChip(
label: 'Search', label: 'Search',
selected: pageType == PageType.search, selected: pageType == _PageType.search,
onSelected: (val) { onSelected: (val) {
if (val) { if (val) {
setState(() { setState(() {
pageType = PageType.search; pageType = _PageType.search;
}); });
} }
}, },
@ -523,11 +518,11 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
CustomChip( CustomChip(
label: 'Settings', label: 'Settings',
selected: pageType == PageType.settings, selected: pageType == _PageType.settings,
onSelected: (val) { onSelected: (val) {
if (val) { if (val) {
setState(() { setState(() {
pageType = PageType.settings; pageType = _PageType.settings;
}); });
} }
}, },
@ -591,6 +586,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() { void onLoginTapped() {
final usernameController = TextEditingController(); final usernameController = TextEditingController();
final passwordController = TextEditingController(); final passwordController = TextEditingController();
@ -853,3 +872,28 @@ class _ProfileScreenState extends State<ProfileScreen>
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }
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),
),
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.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_fadein/flutter_fadein.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
@ -28,13 +31,21 @@ enum _MenuAction {
} }
class StoryScreenArgs { class StoryScreenArgs {
StoryScreenArgs({required this.story}); StoryScreenArgs({
required this.story,
this.onlyShowTargetComment = false,
this.targetComments,
});
final Story story; final Story story;
final bool onlyShowTargetComment;
final List<Comment>? targetComments;
} }
class StoryScreen extends StatefulWidget { 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'; static const String routeName = '/story';
@ -47,9 +58,12 @@ class StoryScreen extends StatefulWidget {
create: (context) => PostCubit(), create: (context) => PostCubit(),
), ),
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (_) => CommentsCubit<Story>( create: (_) => CommentsCubit<Story>()
item: args.story, ..init(
), args.story,
onlyShowTargetComment: args.onlyShowTargetComment,
targetComment: args.targetComments?.last,
),
), ),
BlocProvider<EditCubit>( BlocProvider<EditCubit>(
create: (context) => EditCubit(), create: (context) => EditCubit(),
@ -57,12 +71,14 @@ class StoryScreen extends StatefulWidget {
], ],
child: StoryScreen( child: StoryScreen(
story: args.story, story: args.story,
parentComments: args.targetComments ?? [],
), ),
), ),
); );
} }
final Story story; final Story story;
final List<Comment> parentComments;
@override @override
_StoryScreenState createState() => _StoryScreenState(); _StoryScreenState createState() => _StoryScreenState();
@ -105,6 +121,7 @@ class _StoryScreenState extends State<StoryScreen> {
FeatureDiscovery.discoverFeatures( FeatureDiscovery.discoverFeatures(
context, context,
const <String>{ const <String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList, Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView, Constants.featureOpenStoryInWebView,
}, },
@ -139,15 +156,13 @@ class _StoryScreenState extends State<StoryScreen> {
return BlocConsumer<PostCubit, PostState>( return BlocConsumer<PostCubit, PostState>(
listener: (context, postState) { listener: (context, postState) {
if (postState.status == PostStatus.successful) { if (postState.status == PostStatus.successful) {
editCubit.onReplySubmittedSuccessfully(); final verb =
editCubit.state.replyingTo == null ? 'updated' : 'submitted';
final msg = 'Comment $verb! ${(happyFaces..shuffle()).first}';
focusNode.unfocus(); focusNode.unfocus();
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(SnackBar( showSnackBar(content: msg);
content: Text( editCubit.onReplySubmittedSuccessfully();
'Comment submitted! ${(happyFaces..shuffle()).first}',
),
backgroundColor: Colors.orange,
));
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) { } else if (postState.status == PostStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(
@ -175,10 +190,12 @@ class _StoryScreenState extends State<StoryScreen> {
builder: (context, state) { builder: (context, state) {
return BlocConsumer<EditCubit, EditState>( return BlocConsumer<EditCubit, EditState>(
listenWhen: (previous, current) { listenWhen: (previous, current) {
return previous.replyingTo != current.replyingTo; return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited;
}, },
listener: (context, editState) { listener: (context, editState) {
if (editState.replyingTo != null) { if (editState.replyingTo != null ||
editState.itemBeingEdited != null) {
if (editState.text == null) { if (editState.text == null) {
commentEditingController.clear(); commentEditingController.clear();
} else { } else {
@ -194,6 +211,7 @@ class _StoryScreenState extends State<StoryScreen> {
}, },
builder: (context, editState) { builder: (context, editState) {
final replyingTo = editCubit.state.replyingTo; final replyingTo = editCubit.state.replyingTo;
final editing = editCubit.state.itemBeingEdited;
return Scaffold( return Scaffold(
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
@ -205,12 +223,69 @@ class _StoryScreenState extends State<StoryScreen> {
ScrollUpIconButton( ScrollUpIconButton(
scrollController: scrollController, scrollController: scrollController,
), ),
BlocBuilder<PinCubit, PinState>(
builder: (context, pinState) {
final pinned = pinState.pinnedStoriesIds
.contains(widget.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(widget.story);
} else {
context
.read<PinCubit>()
.pinStory(widget.story);
}
},
),
),
);
},
),
BlocBuilder<FavCubit, FavState>( BlocBuilder<FavCubit, FavState>(
builder: (context, favState) { builder: (context, favState) {
final isFav = final isFav =
favState.favIds.contains(widget.story.id); favState.favIds.contains(widget.story.id);
return IconButton( return IconButton(
icon: DescribedFeatureOverlay( icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor, targetColor: Theme.of(context).primaryColor,
tapTarget: Icon( tapTarget: Icon(
isFav isFav
@ -221,7 +296,7 @@ class _StoryScreenState extends State<StoryScreen> {
featureId: Constants.featureAddStoryToFavList, featureId: Constants.featureAddStoryToFavList,
title: const Text('Fav a Story'), title: const Text('Fav a Story'),
description: const Text( description: const Text(
'Save this article for later.', 'Add it to your favorites.',
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: 16),
), ),
child: Icon( child: Icon(
@ -250,6 +325,8 @@ class _StoryScreenState extends State<StoryScreen> {
), ),
IconButton( IconButton(
icon: DescribedFeatureOverlay( icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor, targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon( tapTarget: const Icon(
Icons.stream, Icons.stream,
@ -274,7 +351,8 @@ class _StoryScreenState extends State<StoryScreen> {
), ),
body: SmartRefresher( body: SmartRefresher(
scrollController: scrollController, scrollController: scrollController,
enablePullUp: true, enablePullUp: !state.onlyShowTargetComment,
enablePullDown: !state.onlyShowTargetComment,
header: WaterDropMaterialHeader( header: WaterDropMaterialHeader(
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
offset: topPadding, offset: topPadding,
@ -317,17 +395,34 @@ class _StoryScreenState extends State<StoryScreen> {
SizedBox( SizedBox(
height: topPadding, height: topPadding,
), ),
InkWell( Slidable(
onTap: () { startActionPane: ActionPane(
setState(() { motion: const BehindMotion(),
if (widget.story != replyingTo) { children: [
commentEditingController.clear(); SlidableAction(
} onPressed: (_) {
editCubit.onItemTapped(widget.story); HapticFeedback.lightImpact();
focusNode.requestFocus(); setState(() {
}); if (widget.story != replyingTo) {
}, commentEditingController.clear();
onLongPress: () => onLongPressed(widget.story), }
editCubit.onReplyTapped(widget.story);
focusNode.requestFocus();
});
},
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.message,
),
SlidableAction(
onPressed: (_) =>
onMorePressed(widget.story),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.more_horiz,
),
],
),
child: Column( child: Column(
children: [ children: [
Padding( Padding(
@ -377,17 +472,37 @@ class _StoryScreenState extends State<StoryScreen> {
), ),
), ),
if (widget.story.text.isNotEmpty) if (widget.story.text.isNotEmpty)
Html( Padding(
data: widget.story.text, padding: const EdgeInsets.symmetric(
onLinkTap: (link, _, __, ___) => horizontal: 10,
LinkUtil.launchUrl(link ?? ''), ),
child: SelectableHtml(
data: widget.story.text,
onLinkTap: (link, _, __, ___) =>
LinkUtil.launchUrl(link ?? ''),
),
), ),
], ],
), ),
), ),
if (widget.story.text.isNotEmpty)
const SizedBox(
height: 8,
),
const Divider( const Divider(
height: 0, 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 && if (state.comments.isEmpty &&
state.status == CommentsStatus.loaded) ...[ state.status == CommentsStatus.loaded) ...[
const SizedBox( const SizedBox(
@ -404,10 +519,16 @@ class _StoryScreenState extends State<StoryScreen> {
(e) => FadeIn( (e) => FadeIn(
child: CommentTile( child: CommentTile(
comment: e, comment: e,
onlyShowTargetComment:
state.onlyShowTargetComment,
targetComments: widget.parentComments.sublist(
0,
max(widget.parentComments.length - 1, 0)),
myUsername: authState.isLoggedIn myUsername: authState.isLoggedIn
? authState.username ? authState.username
: null, : null,
onTap: (cmt) { onReplyTapped: (cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) { if (cmt.deleted || cmt.dead) {
return; return;
} }
@ -416,10 +537,19 @@ class _StoryScreenState extends State<StoryScreen> {
commentEditingController.clear(); commentEditingController.clear();
} }
editCubit.onItemTapped(cmt); editCubit.onReplyTapped(cmt);
focusNode.requestFocus(); focusNode.requestFocus();
}, },
onLongPress: onLongPressed, onEditTapped: (cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
editCubit.onEditTapped(cmt);
focusNode.requestFocus();
},
onMoreTapped: onMorePressed,
onStoryLinkTapped: (link) { onStoryLinkTapped: (link) {
final regex = RegExp(r'\d+$'); final regex = RegExp(r'\d+$');
final match = regex.stringMatch(link) ?? ''; final match = regex.stringMatch(link) ?? '';
@ -461,6 +591,7 @@ class _StoryScreenState extends State<StoryScreen> {
child: ReplyBox( child: ReplyBox(
focusNode: focusNode, focusNode: focusNode,
textEditingController: commentEditingController, textEditingController: commentEditingController,
editing: editing,
replyingTo: replyingTo, replyingTo: replyingTo,
isLoading: postState.status == PostStatus.loading, isLoading: postState.status == PostStatus.loading,
onSendTapped: onSendTapped, onSendTapped: onSendTapped,
@ -483,7 +614,9 @@ class _StoryScreenState extends State<StoryScreen> {
); );
} }
void onLongPressed(Item item) { void onMorePressed(Item item) {
HapticFeedback.lightImpact();
if (item.dead || item.deleted) { if (item.dead || item.deleted) {
return; return;
} }
@ -549,6 +682,9 @@ class _StoryScreenState extends State<StoryScreen> {
? const TextStyle(color: Colors.orange) ? const TextStyle(color: Colors.orange)
: null, : null,
), ),
subtitle: item is Story
? Text(item.score.toString())
: null,
onTap: context.read<VoteCubit>().upvote, onTap: context.read<VoteCubit>().upvote,
), ),
ListTile( ListTile(
@ -680,10 +816,7 @@ class _StoryScreenState extends State<StoryScreen> {
}).then((yesTapped) { }).then((yesTapped) {
if (yesTapped ?? false) { if (yesTapped ?? false) {
context.read<AuthBloc>().add(AuthFlag(item: item)); context.read<AuthBloc>().add(AuthFlag(item: item));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( showSnackBar(content: 'Comment flagged!');
content: Text('Comment flagged!'),
backgroundColor: Colors.orange,
));
} }
}); });
} }
@ -752,10 +885,7 @@ class _StoryScreenState extends State<StoryScreen> {
} else { } else {
context.read<BlocklistCubit>().addToBlocklist(item.by); context.read<BlocklistCubit>().addToBlocklist(item.by);
} }
ScaffoldMessenger.of(context).showSnackBar(SnackBar( showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
content: Text('User ${isBlocked ? 'unblocked' : 'blocked'}!'),
backgroundColor: Colors.orange,
));
} }
}); });
} }
@ -763,7 +893,9 @@ class _StoryScreenState extends State<StoryScreen> {
void onSendTapped() { void onSendTapped() {
final authBloc = context.read<AuthBloc>(); final authBloc = context.read<AuthBloc>();
final postCubit = context.read<PostCubit>(); 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) { if (authBloc.state.isLoggedIn) {
final text = commentEditingController.text; final text = commentEditingController.text;
@ -771,7 +903,9 @@ class _StoryScreenState extends State<StoryScreen> {
return; 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); postCubit.post(text: text, to: replyingTo.id);
} }
} else { } else {
@ -792,12 +926,7 @@ class _StoryScreenState extends State<StoryScreen> {
listener: (context, state) { listener: (context, state) {
if (state.isLoggedIn) { if (state.isLoggedIn) {
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( showSnackBar(content: 'Logged in successfully! $happyFace');
SnackBar(
content: Text('Logged in successfully! $happyFace'),
backgroundColor: Colors.orange,
),
);
} }
}, },
builder: (context, state) { builder: (context, state) {

View File

@ -11,6 +11,7 @@ class ReplyBox extends StatefulWidget {
required this.focusNode, required this.focusNode,
required this.textEditingController, required this.textEditingController,
required this.replyingTo, required this.replyingTo,
required this.editing,
required this.onSendTapped, required this.onSendTapped,
required this.onCloseTapped, required this.onCloseTapped,
required this.onChanged, required this.onChanged,
@ -20,6 +21,7 @@ class ReplyBox extends StatefulWidget {
final FocusNode focusNode; final FocusNode focusNode;
final TextEditingController textEditingController; final TextEditingController textEditingController;
final Item? replyingTo; final Item? replyingTo;
final Item? editing;
final VoidCallback onSendTapped; final VoidCallback onSendTapped;
final VoidCallback onCloseTapped; final VoidCallback onCloseTapped;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
@ -66,42 +68,45 @@ class _ReplyBoxState extends State<ReplyBox> {
), ),
child: Text( child: Text(
widget.replyingTo == null widget.replyingTo == null
? '' ? 'Editing'
: 'Replying ' : 'Replying '
'${widget.replyingTo?.by}', '${widget.replyingTo?.by}',
style: const TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
), ),
), ),
const Spacer(), const Spacer(),
if (widget.replyingTo != null && !widget.isLoading) ...[ if (!widget.isLoading) ...[
AnimatedOpacity( ...[
opacity: expanded ? 1 : 0, if (widget.replyingTo != null)
duration: const Duration(milliseconds: 300), AnimatedOpacity(
child: IconButton( opacity: expanded ? 1 : 0,
key: const Key('quote'), duration: const Duration(milliseconds: 300),
icon: const Icon( child: IconButton(
FeatherIcons.code, 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, color: Colors.orange,
size: 18, 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( IconButton(
key: const Key('close'), key: const Key('close'),
icon: const Icon( icon: const Icon(

View File

@ -26,12 +26,21 @@ class _SubmitScreenState extends State<SubmitScreen> {
final urlEditingController = TextEditingController(); final urlEditingController = TextEditingController();
final textEditingController = TextEditingController(); final textEditingController = TextEditingController();
@override
void dispose() {
super.dispose();
titleEditingController.dispose();
urlEditingController.dispose();
textEditingController.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocConsumer<SubmitCubit, SubmitState>( return BlocConsumer<SubmitCubit, SubmitState>(
listenWhen: (previous, current) => previous.status != current.status, listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) { listener: (context, state) {
if (state.status == SubmitStatus.submitted) { if (state.status == SubmitStatus.submitted) {
Navigator.pop(context);
showSnackBar( showSnackBar(
content: 'Post submitted successfully.', content: 'Post submitted successfully.',
); );
@ -123,6 +132,7 @@ class _SubmitScreenState extends State<SubmitScreen> {
controller: titleEditingController, controller: titleEditingController,
cursorColor: Colors.orange, cursorColor: Colors.orange,
autocorrect: false, autocorrect: false,
maxLength: 80,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Title', hintText: 'Title',
focusedBorder: UnderlineInputBorder( focusedBorder: UnderlineInputBorder(

View File

@ -1,7 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.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/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -11,25 +16,36 @@ class CommentTile extends StatelessWidget {
Key? key, Key? key,
required this.myUsername, required this.myUsername,
required this.comment, required this.comment,
required this.onTap, required this.onReplyTapped,
required this.onLongPress, required this.onMoreTapped,
required this.onEditTapped,
required this.onStoryLinkTapped, required this.onStoryLinkTapped,
this.loadKids = true, this.loadKids = true,
this.onlyShowTargetComment = false,
this.level = 0, this.level = 0,
this.targetComments = const [],
}) : super(key: key); }) : super(key: key);
final String? myUsername; final String? myUsername;
final Comment comment; final Comment comment;
final int level; final int level;
final bool loadKids; final bool loadKids;
final Function(Comment) onTap; final bool onlyShowTargetComment;
final Function(Comment) onLongPress; final Function(Comment) onReplyTapped;
final Function(Comment) onMoreTapped;
final Function(Comment) onEditTapped;
final Function(String) onStoryLinkTapped; final Function(String) onStoryLinkTapped;
final List<Comment> targetComments;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<CommentsCubit>( return BlocProvider<CommentsCubit>(
create: (_) => CommentsCubit<Comment>(item: comment), lazy: false,
create: (_) => CommentsCubit<Comment>()
..init(comment,
onlyShowTargetComment: onlyShowTargetComment,
targetComment:
targetComments.isNotEmpty ? targetComments.last : null),
child: BlocBuilder<CommentsCubit, CommentsState>( child: BlocBuilder<CommentsCubit, CommentsState>(
builder: (context, state) { builder: (context, state) {
return BlocBuilder<PreferenceCubit, PreferenceState>( return BlocBuilder<PreferenceCubit, PreferenceState>(
@ -58,37 +74,64 @@ class CommentTile extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
InkWell( Slidable(
onTap: () => onTap(comment), startActionPane: ActionPane(
onLongPress: () => onLongPress(comment), motion: const BehindMotion(),
onDoubleTap: () { children: [
context.read<CommentsCubit>().collapse(); 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( GestureDetector(
padding: const EdgeInsets.only( behavior: HitTestBehavior.opaque,
left: 6, right: 6, top: 6), onTap: () {
child: Row( HapticFeedback.lightImpact();
children: [ context.read<CommentsCubit>().collapse();
Text( },
comment.by, child: Padding(
style: TextStyle( padding: const EdgeInsets.only(
//255, 152, 0 left: 6, right: 6, top: 6),
color: prefState.showEyeCandy child: Row(
? orange children: [
: color, Text(
comment.by,
style: TextStyle(
//255, 152, 0
color: prefState.showEyeCandy
? orange
: color,
),
), ),
), const Spacer(),
const Spacer(), Text(
Text( comment.postedDate,
comment.postedDate, style: const TextStyle(
style: const TextStyle( color: Colors.grey,
color: Colors.grey, ),
), ),
), ],
], ),
), ),
), ),
if (comment.deleted) if (comment.deleted)
@ -141,7 +184,7 @@ class CommentTile extends StatelessWidget {
top: 6, top: 6,
bottom: 12, bottom: 12,
), ),
child: Linkify( child: SelectableLinkify(
key: ObjectKey(comment), key: ObjectKey(comment),
text: comment.text, text: comment.text,
onOpen: (link) { onOpen: (link) {
@ -169,9 +212,20 @@ class CommentTile extends StatelessWidget {
(e) => FadeIn( (e) => FadeIn(
child: CommentTile( child: CommentTile(
comment: e, comment: e,
onlyShowTargetComment:
onlyShowTargetComment &&
targetComments.length > 1,
targetComments: targetComments
.isNotEmpty
? targetComments.sublist(
0,
max(targetComments.length - 1,
0))
: [],
myUsername: myUsername, myUsername: myUsername,
onTap: onTap, onReplyTapped: onReplyTapped,
onLongPress: onLongPress, onMoreTapped: onMoreTapped,
onEditTapped: onEditTapped,
level: level + 1, level: level + 1,
onStoryLinkTapped: onStoryLinkTapped, onStoryLinkTapped: onStoryLinkTapped,
), ),
@ -195,9 +249,8 @@ class CommentTile extends StatelessWidget {
Theme.of(context).brightness == Brightness.dark Theme.of(context).brightness == Brightness.dark
? 0.03 ? 0.03
: 0.15; : 0.15;
final borderColor = prefState.showCommentBorder && level != 0 final borderColor =
? color.withOpacity(0.5) level != 0 ? color.withOpacity(0.5) : Colors.transparent;
: Colors.transparent;
final commentColor = prefState.showEyeCandy final commentColor = prefState.showEyeCandy
? color.withOpacity(commentBackgroundColorOpacity) ? color.withOpacity(commentBackgroundColorOpacity)
: Colors.transparent; : Colors.transparent;

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; 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_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/custom_circular_progress_indicator.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/screens/widgets/story_tile.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart';
@ -17,6 +19,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
required this.refreshController, required this.refreshController,
this.enablePullDown = true, this.enablePullDown = true,
this.pinnable = false, this.pinnable = false,
this.markReadStories = false,
this.useConsistentFontSize = false,
this.onRefresh, this.onRefresh,
this.onLoadMore, this.onLoadMore,
this.onPinned, this.onPinned,
@ -26,9 +30,14 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final bool showWebPreview; final bool showWebPreview;
final bool enablePullDown; final bool enablePullDown;
final bool markReadStories;
/// Whether story tiles can be pinned to the top. /// Whether story tiles can be pinned to the top.
final bool pinnable; final bool pinnable;
/// Whether to use same font size for comment and story tiles.
final bool useConsistentFontSize;
final List<T> items; final List<T> items;
final Widget? header; final Widget? header;
final RefreshController? refreshController; final RefreshController? refreshController;
@ -43,6 +52,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
children: [ children: [
if (header != null) header!, if (header != null) header!,
...items.map((e) { ...items.map((e) {
final wasRead =
context.read<CacheCubit>().state.storiesReadStatus[e.id] ?? false;
if (e is Story) { if (e is Story) {
return [ return [
FadeIn( FadeIn(
@ -52,11 +63,14 @@ class ItemsListView<T extends Item> extends StatelessWidget {
motion: const BehindMotion(), motion: const BehindMotion(),
children: [ children: [
SlidableAction( SlidableAction(
onPressed: (_) => onPinned?.call(e), onPressed: (_) {
HapticFeedback.lightImpact();
onPinned?.call(e);
},
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
foregroundColor: Colors.white, foregroundColor: Colors.white,
icon: showWebPreview icon: showWebPreview
? Icons.vertical_align_top ? Icons.push_pin_outlined
: null, : null,
label: 'Pin to top', label: 'Pin to top',
), ),
@ -68,6 +82,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
story: e, story: e,
onTap: () => onTap(e), onTap: () => onTap(e),
showWebPreview: showWebPreview, showWebPreview: showWebPreview,
wasRead: markReadStories && wasRead,
simpleTileFontSize: useConsistentFontSize ? 14 : 16,
), ),
), ),
), ),

View File

@ -137,9 +137,9 @@ class WebAnalyzer {
} }
static Future<List<dynamic>?> _isolate(dynamic message) async { static Future<List<dynamic>?> _isolate(dynamic message) async {
//ignore: avoid_dynamic_calls // ignore: avoid_dynamic_calls
final url = message[0] as String; final url = message[0] as String;
//ignore: avoid_dynamic_calls // ignore: avoid_dynamic_calls
final multimedia = message[1] as bool; final multimedia = message[1] as bool;
final info = await _getInfo(url, multimedia); final info = await _getInfo(url, multimedia);

View File

@ -10,14 +10,18 @@ import 'package:shimmer/shimmer.dart';
class StoryTile extends StatelessWidget { class StoryTile extends StatelessWidget {
const StoryTile({ const StoryTile({
Key? key, Key? key,
this.wasRead = false,
required this.showWebPreview, required this.showWebPreview,
required this.story, required this.story,
required this.onTap, required this.onTap,
this.simpleTileFontSize = 16,
}) : super(key: key); }) : super(key: key);
final bool showWebPreview; final bool showWebPreview;
final bool wasRead;
final Story story; final Story story;
final VoidCallback onTap; final VoidCallback onTap;
final double simpleTileFontSize;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -118,7 +122,9 @@ class StoryTile extends StatelessWidget {
bodyMaxLines: 4, bodyMaxLines: 4,
errorTitle: story.title, errorTitle: story.title,
titleStyle: TextStyle( titleStyle: TextStyle(
color: Theme.of(context).textTheme.subtitle1!.color, color: wasRead
? Colors.grey[500]
: Theme.of(context).textTheme.subtitle1!.color,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -145,7 +151,9 @@ class StoryTile extends StatelessWidget {
imagePath: Constants.hackerNewsLogoPath, imagePath: Constants.hackerNewsLogoPath,
bodyMaxLines: 4, bodyMaxLines: 4,
titleTextStyle: TextStyle( titleTextStyle: TextStyle(
color: Theme.of(context).textTheme.subtitle1!.color, color: wasRead
? Colors.grey[500]
: Theme.of(context).textTheme.subtitle1!.color,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -170,7 +178,10 @@ class StoryTile extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
story.title, story.title,
style: const TextStyle(fontSize: 15), style: TextStyle(
color: wasRead ? Colors.grey[500] : null,
fontSize: simpleTileFontSize,
),
), ),
), ),
], ],

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

View File

@ -1 +1,2 @@
export 'cache_service.dart'; export 'cache_service.dart';
export 'firebase_client.dart';

View File

@ -6,10 +6,21 @@ class LinkUtil {
static final _browser = ChromeSafariBrowser(); static final _browser = ChromeSafariBrowser();
static void launchUrl(String link, {bool useReader = false}) { 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) { canLaunch(link).then((val) {
if (val) { if (val) {
final rinsedLink = rinseLink(link);
_browser.open( _browser.open(
url: Uri.parse(link), url: Uri.parse(rinsedLink),
options: ChromeSafariBrowserClassOptions( options: ChromeSafariBrowserClassOptions(
ios: IOSSafariOptions( ios: IOSSafariOptions(
entersReaderIfAvailable: useReader, entersReaderIfAvailable: useReader,

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 0.1.5+20 version: 0.1.9+27
publish_to: none publish_to: none
environment: environment:
@ -18,8 +18,6 @@ dependencies:
equatable: 2.0.3 equatable: 2.0.3
fast_gbk: ^1.0.0 fast_gbk: ^1.0.0
feature_discovery: ^0.14.0 feature_discovery: ^0.14.0
firebase_analytics: ^8.3.4
firebase_core: ^1.6.0
flutter: flutter:
sdk: flutter sdk: flutter
flutter_app_badger: ^1.3.0 flutter_app_badger: ^1.3.0
@ -34,6 +32,7 @@ dependencies:
font_awesome_flutter: ^9.2.0 font_awesome_flutter: ^9.2.0
gbk_codec: ^0.4.0 gbk_codec: ^0.4.0
get_it: 7.2.0 get_it: 7.2.0
hive: ^2.0.6
html: ^0.15.0 html: ^0.15.0
html_unescape: ^2.0.0 html_unescape: ^2.0.0
http: ^0.13.3 http: ^0.13.3
@ -45,6 +44,7 @@ dependencies:
sembast: ^3.1.1+1 sembast: ^3.1.1+1
shared_preferences: ^2.0.11 shared_preferences: ^2.0.11
shimmer: ^2.0.0 shimmer: ^2.0.0
tuple: ^2.0.0
universal_platform: ^1.0.0+1 universal_platform: ^1.0.0+1
url_launcher: ^6.0.10 url_launcher: ^6.0.10