Compare commits

...

2 Commits

Author SHA1 Message Date
9cefffa518 v0.2.20 (#56)
* bumped version.

* fixed web analyzer.

* improved comments loading mechanism.

* fixed delete all button.

* improved reply box logic.

* improved web analyzer.

* allow users to sort comments.

* fixed styles.

* fixed bugs.

* bumped version.

* fixed comments cubit.

* fixed dead comments.
2022-06-21 02:38:24 -07:00
fe630ea7a9 v0.2.20 (#55)
* bumped version.

* fixed web analyzer.

* improved comments loading mechanism.

* fixed delete all button.

* improved reply box logic.

* improved web analyzer.

* allow users to sort comments.

* fixed styles.

* fixed bugs.

* bumped version.

* fixed comments cubit.
2022-06-21 02:15:42 -07:00
16 changed files with 188 additions and 83 deletions

View File

@ -10,8 +10,6 @@ A simple noiseless [Hacker News](https://news.ycombinator.com/) client made with
[![GitHub](https://img.shields.io/github/stars/livinglist/Hacki?style=social)](https://img.shields.io/github/stars/livinglist/Hacki?style=social)
[![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://pub.dev/packages/effective_dart)
<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="assets/images/app_store_badge.png" height="50">](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone) [<img src="assets/images/google_play_badge.png" height="50">](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US) [<img src="assets/images/f_droid_badge.png" height="50">](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 KiB

After

Width:  |  Height:  |  Size: 935 KiB

View File

@ -0,0 +1 @@
- Offline mode now includes web pages.

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 KiB

After

Width:  |  Height:  |  Size: 935 KiB

View File

@ -568,7 +568,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -577,7 +577,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.19;
MARKETING_VERSION = 0.2.20;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -705,7 +705,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -714,7 +714,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.19;
MARKETING_VERSION = 0.2.20;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -736,7 +736,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -745,7 +745,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.19;
MARKETING_VERSION = 0.2.20;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/config/locator.dart';
@ -71,28 +72,34 @@ class CommentsCubit extends Cubit<CommentsState> {
final Story updatedStory = state.offlineReading
? story
: await _storiesRepository.fetchStoryBy(story.id) ?? story;
final List<int> kids = () {
switch (state.order) {
case CommentsOrder.natural:
return updatedStory.kids;
case CommentsOrder.newestFirst:
return updatedStory.kids.sorted((int a, int b) => b.compareTo(a));
case CommentsOrder.oldestFirst:
return updatedStory.kids.sorted((int a, int b) => a.compareTo(b));
}
}();
emit(state.copyWith(story: updatedStory));
if (state.offlineReading) {
_streamSubscription = _cacheRepository
.getCachedCommentsStream(ids: updatedStory.kids)
.getCachedCommentsStream(ids: kids)
.listen(_onCommentFetched)
..onDone(_onDone);
} else {
_streamSubscription = _storiesRepository
.fetchCommentsStream(ids: updatedStory.kids)
.fetchCommentsStream(ids: kids)
.listen(_onCommentFetched)
..onDone(_onDone);
}
}
Future<void> refresh() async {
final bool offlineReading = await _cacheRepository.hasCachedStories;
_cacheService.resetCollapsedComments();
if (offlineReading) {
if (state.offlineReading) {
emit(
state.copyWith(
status: CommentsStatus.loaded,
@ -101,6 +108,10 @@ class CommentsCubit extends Cubit<CommentsState> {
return;
}
_cacheService
..resetComments()
..resetCollapsedComments();
emit(
state.copyWith(
status: CommentsStatus.loading,
@ -113,8 +124,19 @@ class CommentsCubit extends Cubit<CommentsState> {
final Story story = state.story;
final Story updatedStory =
await _storiesRepository.fetchStoryBy(story.id) ?? story;
final List<int> kids = () {
switch (state.order) {
case CommentsOrder.natural:
return updatedStory.kids;
case CommentsOrder.newestFirst:
return updatedStory.kids.sorted((int a, int b) => b.compareTo(a));
case CommentsOrder.oldestFirst:
return updatedStory.kids.sorted((int a, int b) => a.compareTo(b));
}
}();
_streamSubscription = _storiesRepository
.fetchCommentsStream(ids: updatedStory.kids)
.fetchCommentsStream(ids: kids)
.listen(_onCommentFetched)
..onDone(_onDone);
@ -161,7 +183,7 @@ class CommentsCubit extends Cubit<CommentsState> {
..cacheComment(comment);
_sembastRepository.cacheComment(comment);
final List<LinkifyElement> elements = linkify(
final List<LinkifyElement> elements = _linkify(
comment.text,
);
@ -194,7 +216,14 @@ class CommentsCubit extends Cubit<CommentsState> {
}
}
List<LinkifyElement> linkify(
void onOrderChanged(CommentsOrder? order) {
if (order == null) return;
_streamSubscription?.cancel();
emit(state.copyWith(order: order, comments: <Comment>[]));
init();
}
static List<LinkifyElement> _linkify(
String text, {
LinkifyOptions options = const LinkifyOptions(),
List<Linkifier> linkifiers = const <Linkifier>[

View File

@ -8,12 +8,18 @@ enum CommentsStatus {
failure,
}
enum CommentsOrder {
natural,
newestFirst,
oldestFirst,
}
class CommentsState extends Equatable {
const CommentsState({
required this.story,
required this.comments,
required this.status,
required this.collapsed,
required this.order,
required this.onlyShowTargetComment,
required this.offlineReading,
required this.currentPage,
@ -24,14 +30,14 @@ class CommentsState extends Equatable {
required this.story,
}) : comments = <Comment>[],
status = CommentsStatus.init,
collapsed = false,
order = CommentsOrder.natural,
onlyShowTargetComment = false,
currentPage = 0;
final Story story;
final List<Comment> comments;
final CommentsStatus status;
final bool collapsed;
final CommentsOrder order;
final bool onlyShowTargetComment;
final bool offlineReading;
final int currentPage;
@ -40,7 +46,7 @@ class CommentsState extends Equatable {
Story? story,
List<Comment>? comments,
CommentsStatus? status,
bool? collapsed,
CommentsOrder? order,
bool? onlyShowTargetComment,
bool? offlineReading,
int? currentPage,
@ -49,7 +55,7 @@ class CommentsState extends Equatable {
story: story ?? this.story,
comments: comments ?? this.comments,
status: status ?? this.status,
collapsed: collapsed ?? this.collapsed,
order: order ?? this.order,
onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading,
@ -62,7 +68,7 @@ class CommentsState extends Equatable {
story,
comments,
status,
collapsed,
order,
onlyShowTargetComment,
offlineReading,
currentPage,

View File

@ -179,10 +179,6 @@ class HackiApp extends StatelessWidget {
lazy: false,
create: (BuildContext context) => PostCubit(),
),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
],
child: AdaptiveTheme(
light: ThemeData(

View File

@ -11,6 +11,7 @@ class BuildableComment extends Comment {
required super.by,
required super.text,
required super.kids,
required super.dead,
required super.deleted,
required super.level,
required this.elements,
@ -25,6 +26,7 @@ class BuildableComment extends Comment {
by: comment.by,
text: comment.text,
kids: comment.kids,
dead: comment.dead,
deleted: comment.deleted,
level: comment.level,
);

View File

@ -12,11 +12,11 @@ class Comment extends Item {
required super.by,
required super.text,
required super.kids,
required super.dead,
required super.deleted,
required this.level,
}) : super(
descendants: 0,
dead: false,
parts: <int>[],
title: '',
url: '',
@ -55,6 +55,7 @@ class Comment extends Item {
by: by,
text: text,
kids: kids,
dead: dead,
deleted: deleted,
level: level ?? this.level,
);

View File

@ -408,7 +408,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showAboutDialog(
context: context,
applicationName: 'Hacki',
applicationVersion: 'v0.2.19',
applicationVersion: 'v0.2.20',
applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(12),
@ -676,7 +676,7 @@ class _ProfileScreenState extends State<ProfileScreen>
.get<SembastRepository>()
.deleteAllCachedComments()
.whenComplete(
locator.get<SembastRepository>().deleteAllCachedComments,
locator.get<CacheRepository>().deleteAll,
)
.whenComplete(
locator.get<PreferenceRepository>().clearAllReadStories,

View File

@ -76,6 +76,10 @@ class StoryScreen extends StatefulWidget {
targetParents: args.targetComments,
),
),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
if (args.story.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
@ -112,6 +116,10 @@ class StoryScreen extends StatefulWidget {
targetParents: args.targetComments,
),
),
BlocProvider<EditCubit>(
lazy: false,
create: (BuildContext context) => EditCubit(),
),
if (args.story.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
@ -187,6 +195,7 @@ class _StoryScreenState extends State<StoryScreen> {
scrollController.dispose();
storyLinkTapThrottle.dispose();
featureDiscoveryDismissThrottle.dispose();
focusNode.dispose();
super.dispose();
}
@ -271,16 +280,18 @@ class _StoryScreenState extends State<StoryScreen> {
controller: refreshController,
onRefresh: () {
HapticFeedback.lightImpact();
locator.get<CacheService>().resetComments();
if (context.read<StoriesBloc>().state.offlineReading) {
refreshController.refreshCompleted();
} else {
context.read<CommentsCubit>().refresh();
if (widget.story.isPoll) {
context.read<PollCubit>().refresh();
}
}
},
onLoading: () {
context.read<CommentsCubit>().loadMore();
},
onLoading: context.read<CommentsCubit>().loadMore,
child: ListView(
primary: false,
children: <Widget>[
@ -432,6 +443,59 @@ class _StoryScreenState extends State<StoryScreen> {
const Divider(
height: 0,
),
] else ...<Widget>[
Row(
children: <Widget>[
const SizedBox(
width: 12,
),
Text(
'''${state.story.score} karma, ${state.story.descendants} comment${state.story.descendants > 1 ? 's' : ''}''',
),
const Spacer(),
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: const <DropdownMenuItem<CommentsOrder>>[
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.natural,
child: Text(
'Natural',
style: TextStyle(
fontSize: 14,
),
),
),
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.newestFirst,
child: Text(
'Newest first',
style: TextStyle(
fontSize: 14,
),
),
),
DropdownMenuItem<CommentsOrder>(
value: CommentsOrder.oldestFirst,
child: Text(
'Oldest first',
style: TextStyle(
fontSize: 14,
),
),
),
],
onChanged:
context.read<CommentsCubit>().onOrderChanged,
),
const SizedBox(
width: 4,
),
],
),
const Divider(
height: 0,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.allLoaded) ...<Widget>[
@ -530,8 +594,10 @@ class _StoryScreenState extends State<StoryScreen> {
SplitViewState current,
) =>
previous.expanded != current.expanded,
builder:
(BuildContext context, SplitViewState state) {
builder: (
BuildContext context,
SplitViewState state,
) {
return Positioned(
top: 0,
left: 0,

View File

@ -109,7 +109,7 @@ class LinkPreview extends StatefulWidget {
class _LinkPreviewState extends State<LinkPreview> {
InfoBase? _info;
String? _errorTitle, _errorBody, _url;
String? _errorTitle, _errorBody;
bool _loading = false;
@override
@ -119,37 +119,19 @@ class _LinkPreviewState extends State<LinkPreview> {
'Oops! Unable to parse the url. We have '
'sent feedback to our developers & '
'we will try to fix this in our next release. Thanks!';
_url = widget.link.trim();
if (_url?.isNotEmpty ?? false) {
_info = WebAnalyzer.getInfoFromCache(_url);
} else {
_info = WebAnalyzer.getInfoFromCache(widget.story.id.toString());
}
if (_info == null) {
_loading = true;
_getInfo();
}
super.initState();
}
Future<void> _getInfo() async {
if (_url!.startsWith('http') || _url!.startsWith('https')) {
_info = await WebAnalyzer.getInfo(
_url,
story: widget.story,
cache: widget.cache,
offlineReading: widget.offlineReading,
);
} else {
_info = await WebAnalyzer.getInfo(
null,
story: widget.story,
cache: widget.cache,
offlineReading: widget.offlineReading,
);
}
if (mounted) {
setState(() {
@ -193,7 +175,7 @@ class _LinkPreviewState extends State<LinkPreview> {
metadata: widget.story.simpleMetadata,
url: widget.link,
title: widget.story.title,
description: desc ?? title ?? 'no comments yet.',
description: desc ?? title ?? 'no comment yet.',
imageUri: imageUri,
imagePath: Constants.hackerNewsLogoPath,
onTap: _launchURL,

View File

@ -95,12 +95,12 @@ class WebAnalyzer {
/// Get web information
/// return [InfoBase]
static InfoBase? getInfoFromCache(String? url) {
final InfoBase? info = cacheMap[url];
static InfoBase? getInfoFromCache(String? cacheKey) {
final InfoBase? info = cacheMap[cacheKey];
if (info != null) {
if (!info._timeout.isAfter(DateTime.now())) {
cacheMap.remove(url);
cacheMap.remove(cacheKey);
}
}
return info;
@ -108,14 +108,16 @@ class WebAnalyzer {
/// Get web information
/// return [InfoBase]
static Future<InfoBase?> getInfo(
String? url, {
static Future<InfoBase?> getInfo({
required Story story,
Duration cache = const Duration(hours: 24),
bool multimedia = true,
required bool offlineReading,
}) async {
InfoBase? info = getInfoFromCache(url);
final String key = getKey(story);
final String url = story.url;
InfoBase? info = getInfoFromCache(key);
if (info != null) return info;
@ -126,7 +128,8 @@ class WebAnalyzer {
)
.._timeout = DateTime.now().add(cache)
.._shouldRetry = false;
cacheMap[story.id.toString()] = info;
cacheMap[key] = info;
return info;
}
@ -148,7 +151,9 @@ class WebAnalyzer {
)
.._shouldRetry = false
.._timeout = DateTime.now();
cacheMap[url] = info;
cacheMap[key] = info;
return info;
}
@ -161,7 +166,7 @@ class WebAnalyzer {
if (info != null && !info._shouldRetry) {
info._timeout = DateTime.now().add(cache);
cacheMap[url] = info;
cacheMap[key] = info;
}
return info;
@ -214,12 +219,12 @@ class WebAnalyzer {
if (res == null || isEmpty(res[2] as String?)) {
final String? commentText = await compute(
_fetchInfoFromStoryId,
story.kids,
_fetchInfoFromStory,
<int>[story.id, ...story.kids],
);
shouldRetry = commentText == null;
fallbackDescription = commentText ?? 'no comments yet';
fallbackDescription = commentText ?? 'no comment yet';
} else {
shouldRetry = false;
}
@ -281,11 +286,25 @@ class WebAnalyzer {
}
}
static Future<String?> _fetchInfoFromStoryId(List<int> kids) async {
static Future<String?> _fetchInfoFromStory(List<int> meta) async {
final StoriesRepository storiesRepository = StoriesRepository();
final int storyId = meta.first;
List<int> kids = meta.sublist(1, meta.length);
// Kids of stories from search results are always empty, so here we try
// to fetch the story itself first and see if the kids are still empty.
if (kids.isEmpty) {
final Story? story = await storiesRepository.fetchStoryBy(storyId);
if (story == null) return null;
kids = story.kids;
if (kids.isEmpty) return null;
}
final Comment? comment =
await StoriesRepository().fetchCommentBy(id: kids.first);
await storiesRepository.fetchCommentBy(id: kids.first);
return comment != null ? '${comment.by}: ${comment.text}' : null;
}
@ -540,4 +559,7 @@ class WebAnalyzer {
}
return source;
}
static String getKey(Story story) =>
story.url.isNotEmpty ? story.url : story.id.toString();
}

View File

@ -1,6 +1,6 @@
name: hacki
description: A Hacker News reader.
version: 0.2.19+60
version: 0.2.20+62
publish_to: none
environment: