Compare commits

...

20 Commits

Author SHA1 Message Date
c375def289 bugfixes. (#169) 2023-02-26 12:12:11 -08:00
3469543c7b update action menu. (#168) 2023-02-26 02:40:11 -08:00
ab755581fd add favorite to action menu. (#167) 2023-02-25 23:16:55 -08:00
6b75eb8549 bump version. (#165) 2023-02-24 11:41:19 -08:00
36ded8a8e3 improve search experience. (#164) 2023-02-24 10:38:10 -08:00
582ac7b0be fix push notification. (#161) 2023-02-23 23:14:06 -08:00
e5e3391785 bump flutter version. (#160) 2023-02-23 01:04:44 -08:00
9159fe0fe1 add font customization. (#159) 2023-02-22 15:54:01 -08:00
7c51bad35e add shortcuts for wikipedia and wiktionary. (#157) 2023-02-22 13:37:32 -08:00
6836138d11 fix quote rendering. (#158) 2023-02-22 11:30:33 -08:00
2f71964277 linkifier cleanup. (#156) 2023-02-22 00:15:52 -08:00
c24c5c1b7a add formatting support (#155) 2023-02-21 23:40:25 -08:00
755b112382 remove isFirstLaunch val. (#153) 2023-02-12 20:22:38 -08:00
d44b64d249 fix feature discovery. (#152) 2023-02-12 19:39:51 -08:00
35ed917e66 improve onboarding experience. (#151) 2023-02-12 18:49:17 -08:00
15b75ef37c cleanup. (#149) 2023-02-11 19:44:54 -08:00
f39408fbcc fix time machine. (#148) 2023-02-11 04:17:43 -08:00
ca2f063297 update comment tile. (#146) 2023-02-11 02:30:51 -08:00
1ad231adbb fix offline mode. (#145) 2023-02-11 01:33:45 -08:00
60b09fd81e cleanup. (#143) 2023-02-11 00:39:30 -08:00
88 changed files with 2115 additions and 1227 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,5 @@
- Customization of tab bar.
- Option to enable swipe gesture for switching between tabs.
- Access to action menu from home screen.
- Access to Wikipedia and Wiktionary from text selection toolbar.
- Quotes and emphasis rendering.

View File

@ -0,0 +1,5 @@
- Customization of tab bar.
- Option to enable swipe gesture for switching between tabs.
- Access to action menu from home screen.
- Access to Wikipedia and Wiktionary from text selection toolbar.
- Quotes and emphasis rendering.

View File

@ -137,7 +137,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6

View File

@ -18,6 +18,8 @@ import flutter_local_notifications
center.delegate = self
WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!)
WorkmanagerPlugin.registerTask(withIdentifier: "workmanager.background.task")
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate

View File

@ -41,20 +41,20 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((bool loggedIn) async {
if (loggedIn) {
final String? username = await _authRepository.username;
final User user =
await _storiesRepository.fetchUserBy(userId: username!);
final User user = await _storiesRepository.fetchUser(id: username!);
emit(
state.copyWith(
isLoggedIn: true,
user: user,
status: AuthStatus.loaded,
),
);
} else {
emit(
state.copyWith(
status: AuthStatus.loaded,
isLoggedIn: false,
status: AuthStatus.loaded,
),
);
}
@ -84,8 +84,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
);
if (successful) {
final User user =
await _storiesRepository.fetchUserBy(userId: event.username);
final User user = await _storiesRepository.fetchUser(id: event.username);
emit(
state.copyWith(
user: user,

View File

@ -56,15 +56,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
static const int _tabletSmallPageSize = 15;
static const int _tabletLargePageSize = 25;
/// Types of story to be shown in the tab bar.
static const Set<StoryType> types = <StoryType>{
StoryType.top,
StoryType.best,
StoryType.latest,
StoryType.ask,
StoryType.show,
};
Future<void> onInitialize(
StoriesInitialize event,
Emitter<StoriesState> emit,
@ -72,7 +63,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
_streamSubscription ??=
_preferenceCubit.stream.listen((PreferenceState event) {
final bool isComplexTile = event.complexStoryTileEnabled;
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
final int pageSize = getPageSize(isComplexTile: isComplexTile);
if (pageSize != state.currentPageSize) {
add(StoriesPageSizeChanged(pageSize: pageSize));
@ -80,7 +71,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
});
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
final int pageSize = getPageSize(isComplexTile: isComplexTile);
emit(
const StoriesState.init().copyWith(
offlineReading: hasCachedStories &&
@ -92,44 +83,45 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
storiesToBeDownloaded: state.storiesToBeDownloaded,
),
);
for (final StoryType type in types) {
await loadStories(of: type, emit: emit);
for (final StoryType type in StoryType.values) {
await loadStories(type: type, emit: emit);
}
}
Future<void> loadStories({
required StoryType of,
required StoryType type,
required Emitter<StoriesState> emit,
}) async {
if (state.offlineReading) {
final List<int> ids = await _offlineRepository.getCachedStoryIds(of: of);
final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type);
emit(
state
.copyWithStoryIdsUpdated(of: of, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0),
.copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0),
);
_offlineRepository
.getCachedStoriesStream(
ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
)
.listen((Story story) {
add(StoryLoaded(story: story, type: of));
add(StoryLoaded(story: story, type: type));
}).onDone(() {
add(StoriesLoaded(type: of));
add(StoriesLoaded(type: type));
});
} else {
final List<int> ids = await _storiesRepository.fetchStoryIds(of: of);
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
emit(
state
.copyWithStoryIdsUpdated(of: of, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0),
.copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0),
);
_storiesRepository
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
.listen((Story story) {
add(StoryLoaded(story: story, type: of));
add(StoryLoaded(story: story, type: type));
}).onDone(() {
add(StoriesLoaded(type: of));
add(StoriesLoaded(type: type));
});
}
}
@ -140,7 +132,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
) async {
emit(
state.copyWithStatusUpdated(
of: event.type,
type: event.type,
to: StoriesStatus.loading,
),
);
@ -148,27 +140,29 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
if (state.offlineReading) {
emit(
state.copyWithStatusUpdated(
of: event.type,
type: event.type,
to: StoriesStatus.loaded,
),
);
} else {
emit(state.copyWithRefreshed(of: event.type));
await loadStories(of: event.type, emit: emit);
emit(state.copyWithRefreshed(type: event.type));
await loadStories(type: event.type, emit: emit);
}
}
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
emit(
state.copyWithStatusUpdated(
of: event.type,
type: event.type,
to: StoriesStatus.loading,
),
);
final int currentPage = state.currentPageByType[event.type]!;
final int len = state.storyIdsByType[event.type]!.length;
emit(state.copyWithCurrentPageUpdated(of: event.type, to: currentPage + 1));
emit(
state.copyWithCurrentPageUpdated(type: event.type, to: currentPage + 1),
);
final int currentPageSize = state.currentPageSize;
final int lower = currentPageSize * (currentPage + 1);
int upper = currentPageSize + lower;
@ -218,7 +212,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} else {
emit(
state.copyWithStatusUpdated(
of: event.type,
type: event.type,
to: StoriesStatus.loaded,
),
);
@ -232,7 +226,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
emit(
state.copyWithStoryAdded(
of: event.type,
type: event.type,
story: event.story,
hasRead: hasRead,
),
@ -240,7 +234,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
emit(state.copyWithStatusUpdated(of: event.type, to: StoriesStatus.loaded));
emit(
state.copyWithStatusUpdated(type: event.type, to: StoriesStatus.loaded),
);
}
Future<void> onDownload(
@ -258,12 +254,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.deleteAllComments();
final Set<int> prioritizedIds = <int>{};
final List<StoryType> prioritizedTypes = <StoryType>[...types]
/// Prioritizing all types of stories except StoryType.latest since
/// new stories tend to have less or no comment at all.
final List<StoryType> prioritizedTypes = <StoryType>[...StoryType.values]
..remove(StoryType.latest);
for (final StoryType type in prioritizedTypes) {
final List<int> ids = await _storiesRepository.fetchStoryIds(of: type);
await _offlineRepository.cacheStoryIds(of: type, ids: ids);
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
await _offlineRepository.cacheStoryIds(type: type, ids: ids);
prioritizedIds.addAll(ids);
}
@ -283,9 +282,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final Set<int> latestIds = <int>{};
final List<int> ids = await _storiesRepository.fetchStoryIds(
of: StoryType.latest,
type: StoryType.latest,
);
await _offlineRepository.cacheStoryIds(of: StoryType.latest, ids: ids);
await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids);
latestIds.addAll(ids);
await fetchAndCacheStories(
@ -311,10 +310,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
downloadStatus: StoriesDownloadStatus.canceled,
),
);
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
}
Future<void> fetchAndCacheStories(
@ -322,11 +317,25 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required bool includingWebPage,
required bool isPrioritized,
}) async {
final List<StreamSubscription<Comment>> downloadStreams =
<StreamSubscription<Comment>>[];
for (final int id in ids) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) break;
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading');
for (final StreamSubscription<Comment> stream in downloadStreams) {
await stream.cancel();
}
_logger.d('deleting downloaded contents');
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
break;
}
_logger.d('fetching story $id');
final Story? story = await _storiesRepository.fetchStoryBy(id);
final Story? story = await _storiesRepository.fetchStory(id: id);
if (story == null) {
if (isPrioritized) {
@ -349,17 +358,37 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.cacheUrl(url: story.url);
}
_storiesRepository
/// Not awaiting the completion of comments stream because otherwise
/// it's going to take forever to finish downloading all the stories
/// since we need to make a single http call for each comment.
///
/// In other words, we are prioritizing the story itself instead of
/// the comments in the story.
late final StreamSubscription<Comment>? downloadStream;
downloadStream = _storiesRepository
.fetchAllChildrenComments(ids: story.kids)
.whereType<Comment>()
.listen(
(Comment comment) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading from comments stream');
downloadStream?.cancel();
return;
}
_logger.d('fetched comment ${comment.id}');
unawaited(
_offlineRepository.cacheComment(comment: comment),
);
},
).onDone(() => add(StoryDownloaded(skipped: false)));
)..onDone(() {
_logger.d(
'''finished downloading story ${story.id} with ${story.descendants} comments''',
);
add(StoryDownloaded(skipped: false));
});
downloadStreams.add(downloadStream);
}
}
@ -443,7 +472,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
bool hasRead(Story story) => state.readStoriesIds.contains(story.id);
int _getPageSize({required bool isComplexTile}) {
int getPageSize({required bool isComplexTile}) {
int pageSize = isComplexTile ? _smallPageSize : _largePageSize;
if (deviceScreenType != DeviceScreenType.mobile) {

View File

@ -103,13 +103,13 @@ class StoriesState extends Equatable {
}
StoriesState copyWithStoryAdded({
required StoryType of,
required StoryType type,
required Story story,
required bool hasRead,
}) {
final Map<StoryType, List<Story>> newMap =
Map<StoryType, List<Story>>.from(storiesByType);
newMap[of] = List<Story>.from(newMap[of]!)..add(story);
newMap[type] = List<Story>.from(newMap[type]!)..add(story);
return copyWith(
storiesByType: newMap,
readStoriesIds: <int>{
@ -120,54 +120,54 @@ class StoriesState extends Equatable {
}
StoriesState copyWithStoryIdsUpdated({
required StoryType of,
required StoryType type,
required List<int> to,
}) {
final Map<StoryType, List<int>> newMap =
Map<StoryType, List<int>>.from(storyIdsByType);
newMap[of] = to;
newMap[type] = to;
return copyWith(
storyIdsByType: newMap,
);
}
StoriesState copyWithStatusUpdated({
required StoryType of,
required StoryType type,
required StoriesStatus to,
}) {
final Map<StoryType, StoriesStatus> newMap =
Map<StoryType, StoriesStatus>.from(statusByType);
newMap[of] = to;
newMap[type] = to;
return copyWith(
statusByType: newMap,
);
}
StoriesState copyWithCurrentPageUpdated({
required StoryType of,
required StoryType type,
required int to,
}) {
final Map<StoryType, int> newMap =
Map<StoryType, int>.from(currentPageByType);
newMap[of] = to;
newMap[type] = to;
return copyWith(
currentPageByType: newMap,
);
}
StoriesState copyWithRefreshed({required StoryType of}) {
StoriesState copyWithRefreshed({required StoryType type}) {
final Map<StoryType, List<Story>> newStoriesMap =
Map<StoryType, List<Story>>.from(storiesByType);
newStoriesMap[of] = <Story>[];
newStoriesMap[type] = <Story>[];
final Map<StoryType, List<int>> newStoryIdsMap =
Map<StoryType, List<int>>.from(storyIdsByType);
newStoryIdsMap[of] = <int>[];
newStoryIdsMap[type] = <int>[];
final Map<StoryType, StoriesStatus> newStatusMap =
Map<StoryType, StoriesStatus>.from(statusByType);
newStatusMap[of] = StoriesStatus.loading;
newStatusMap[type] = StoriesStatus.loading;
final Map<StoryType, int> newCurrentPageMap =
Map<StoryType, int>.from(currentPageByType);
newCurrentPageMap[of] = 0;
newCurrentPageMap[type] = 0;
return copyWith(
storiesByType: newStoriesMap,
storyIdsByType: newStoryIdsMap,

View File

@ -16,6 +16,8 @@ abstract class Constants {
'https://news.ycombinator.com/newsguidelines.html';
static const String githubIssueLink =
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
static const String supportEmail = 'georgefung98@gmail.com';
static const String _imagePath = 'assets/images';
@ -57,10 +59,18 @@ abstract class Constants {
'(ㆆ_ㆆ)',
].pickRandomly()!;
static final String magicWord = <String>[
'to be over the rainbow!',
'to infinity and beyond!',
'to see the future.',
].pickRandomly()!;
static final String errorMessage = 'Something went wrong...$sadFace';
static final String loginErrorMessage =
'''Failed to log in $sadFace, this could happen if your account requires a CAPTCHA, please try logging in inside a browser to see if this is the case, if so, you may try logging in here again later after CAPTCHA is no longer needed.''';
}
abstract class RegExpConstants {
static const String linkSuffix = r'(\)|])(.)*$';
static const String linkSuffix = r'(\)|]|,|\*)(.)*$';
static const String number = '[0-9]+';
}

View File

@ -3,15 +3,18 @@ import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/linkifier_util.dart';
import 'package:linkify/linkify.dart';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
part 'comments_state.dart';
@ -73,12 +76,12 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> init({
bool onlyShowTargetComment = false,
bool useCommentCache = false,
List<Comment>? targetParents,
List<Comment>? targetAncestors,
}) async {
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) {
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
emit(
state.copyWith(
comments: targetParents,
comments: targetAncestors,
onlyShowTargetComment: true,
status: CommentsStatus.allLoaded,
),
@ -86,9 +89,11 @@ class CommentsCubit extends Cubit<CommentsState> {
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: targetParents!.last.kids,
level: targetParents.last.level + 1,
ids: targetAncestors!.last.kids,
level: targetAncestors.last.level + 1,
)
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
@ -106,38 +111,38 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item;
final Item updatedItem = state.offlineReading
? item
: await _storiesRepository.fetchItemBy(id: item.id) ?? item;
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
item;
final List<int> kids = sortKids(updatedItem.kids);
emit(state.copyWith(item: updatedItem));
late final Stream<Comment> commentStream;
if (state.offlineReading) {
_streamSubscription = _offlineRepository
.getCachedCommentsStream(ids: kids)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
} else {
switch (state.fetchMode) {
case FetchMode.lazy:
_streamSubscription = _storiesRepository
.fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _storiesRepository.fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
break;
case FetchMode.eager:
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
break;
}
}
_streamSubscription = commentStream
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
}
Future<void> refresh() async {
@ -173,25 +178,26 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item;
final Item updatedItem =
await _storiesRepository.fetchItemBy(id: item.id) ?? item;
await _storiesRepository.fetchItem(id: item.id) ?? item;
final List<int> kids = sortKids(updatedItem.kids);
late final Stream<Comment> commentStream;
if (state.fetchMode == FetchMode.lazy) {
_streamSubscription = _storiesRepository
.fetchCommentsStream(
ids: kids,
)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _storiesRepository.fetchCommentsStream(
ids: kids,
);
} else {
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: kids,
)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
);
}
_streamSubscription = commentStream
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
emit(
state.copyWith(
item: updatedItem,
@ -227,23 +233,18 @@ class CommentsCubit extends Cubit<CommentsState> {
final StreamSubscription<Comment> streamSubscription =
_storiesRepository
.fetchCommentsStream(ids: comment.kids)
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt);
final List<LinkifyElement> elements = _linkify(
cmt.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(cmt, elements: elements);
emit(
state.copyWith(
comments: <Comment>[...state.comments]..insert(
state.comments.indexOf(comment) + offset + 1,
buildableComment.copyWith(level: level),
cmt.copyWith(level: level),
),
),
);
@ -273,8 +274,9 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> loadParentThread() async {
unawaited(HapticFeedback.lightImpact());
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
final Story? parent =
await _storiesRepository.fetchParentStory(id: state.item.id);
final Story? parent = await _storiesRepository
.fetchParentStory(id: state.item.id)
.then(_toBuildableStory);
if (parent == null) {
return;
@ -340,22 +342,15 @@ class CommentsCubit extends Cubit<CommentsState> {
);
}
void _onCommentFetched(Comment? comment) {
void _onCommentFetched(BuildableComment? comment) {
if (comment != null) {
_collapseCache.addKid(comment.id, to: comment.parent);
_commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment);
final List<LinkifyElement> elements = _linkify(
comment.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(comment, elements: elements);
final List<Comment> updatedComments = <Comment>[
...state.comments,
buildableComment
comment
];
emit(state.copyWith(comments: updatedComments));
@ -387,29 +382,51 @@ class CommentsCubit extends Cubit<CommentsState> {
}
}
static List<LinkifyElement> _linkify(
String text, {
LinkifyOptions options = const LinkifyOptions(),
List<Linkifier> linkifiers = const <Linkifier>[
UrlLinkifier(),
EmailLinkifier(),
],
}) {
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
static Future<Item?> _toBuildable(Item? item) async {
if (item == null) return null;
if (text.isEmpty) {
return <LinkifyElement>[];
switch (item.runtimeType) {
case Comment:
return _toBuildableComment(item as Comment);
case Story:
return _toBuildableStory(item as Story);
}
if (linkifiers.isEmpty) {
return list;
return null;
}
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
if (comment == null) return null;
final List<LinkifyElement> elements =
await compute<String, List<LinkifyElement>>(
LinkifierUtil.linkify,
comment.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(comment, elements: elements);
return buildableComment;
}
static Future<BuildableStory?> _toBuildableStory(Story? story) async {
if (story == null) {
return null;
} else if (story.text.isEmpty) {
return BuildableStory.fromTitleOnlyStory(story);
}
for (final Linkifier linkifier in linkifiers) {
list = linkifier.parse(list, options);
}
final List<LinkifyElement> elements =
await compute<String, List<LinkifyElement>>(
LinkifierUtil.linkify,
story.text,
);
return list;
final BuildableStory buildableStory =
BuildableStory.fromStory(story, elements: elements);
return buildableStory;
}
@override

View File

@ -73,7 +73,7 @@ class FavCubit extends Cubit<FavState> {
),
);
final Item? item = await _storiesRepository.fetchItemBy(id: id);
final Item? item = await _storiesRepository.fetchItem(id: id);
if (item == null) return;

View File

@ -28,7 +28,7 @@ class HistoryCubit extends Cubit<HistoryState> {
final String username = authState.username;
_storiesRepository
.fetchSubmitted(of: username)
.fetchSubmitted(userId: username)
.then((List<int>? submittedIds) {
emit(
state.copyWith(
@ -94,7 +94,7 @@ class HistoryCubit extends Cubit<HistoryState> {
);
_storiesRepository
.fetchSubmitted(of: username)
.fetchSubmitted(userId: username)
.then((List<int>? submittedIds) {
emit(state.copyWith(submittedIds: submittedIds));
if (submittedIds != null) {

View File

@ -81,7 +81,7 @@ class NotificationCubit extends Cubit<NotificationState> {
for (final int id in commentsToBeLoaded) {
Comment? comment = await _sembastRepository.getComment(id: id);
comment ??= await _storiesRepository.fetchCommentBy(id: id);
comment ??= await _storiesRepository.fetchComment(id: id);
if (comment != null) {
emit(
state.copyWith(
@ -159,7 +159,7 @@ class NotificationCubit extends Cubit<NotificationState> {
for (final int id in commentsToBeLoaded) {
Comment? comment = await _sembastRepository.getComment(id: id);
comment ??= await _storiesRepository.fetchCommentBy(id: id);
comment ??= await _storiesRepository.fetchComment(id: id);
if (comment != null) {
emit(state.copyWith(comments: <Comment>[...state.comments, comment]));
}
@ -184,7 +184,7 @@ class NotificationCubit extends Cubit<NotificationState> {
Future<void> _fetchReplies() {
return _storiesRepository
.fetchSubmitted(of: _authBloc.state.username)
.fetchSubmitted(userId: _authBloc.state.username)
.then((List<int>? submittedItems) async {
if (submittedItems != null) {
final List<int> subscribedItems = submittedItems.sublist(
@ -193,7 +193,7 @@ class NotificationCubit extends Cubit<NotificationState> {
);
for (final int id in subscribedItems) {
await _storiesRepository.fetchItemBy(id: id).then((Item? item) async {
await _storiesRepository.fetchItem(id: id).then((Item? item) async {
final List<int> kids = item?.kids ?? <int>[];
final List<int> previousKids =
(await _sembastRepository.kids(of: id)) ?? <int>[];
@ -216,7 +216,7 @@ class NotificationCubit extends Cubit<NotificationState> {
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
);
await _storiesRepository
.fetchCommentBy(id: newCommentId)
.fetchComment(id: newCommentId)
.then((Comment? comment) {
if (comment != null && !comment.dead && !comment.deleted) {
_sembastRepository

View File

@ -33,7 +33,7 @@ class PollCubit extends Cubit<PollState> {
if (pollOptionsIds.isEmpty || refresh) {
final Story? updatedStory =
await _storiesRepository.fetchStoryBy(_story.id);
await _storiesRepository.fetchStory(id: _story.id);
if (updatedStory != null) {
pollOptionsIds = updatedStory.parts;

View File

@ -96,6 +96,9 @@ class PreferenceState extends Equatable {
FontSize get fontSize => FontSize.values
.elementAt(preferences.singleWhereType<FontSizePreference>().val);
Font get font =>
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
@override
List<Object?> get props => <Object?>[
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),

View File

@ -15,19 +15,19 @@ class SearchCubit extends Cubit<SearchState> {
final SearchRepository _searchRepository;
StreamSubscription<Story>? streamSubscription;
StreamSubscription<Item>? streamSubscription;
void search(String query) {
streamSubscription?.cancel();
emit(
state.copyWith(
results: <Story>[],
results: <Item>[],
status: SearchStatus.loading,
params: state.params.copyWith(query: query, page: 0),
),
);
streamSubscription =
_searchRepository.search(params: state.params).listen(_onStoryFetched)
_searchRepository.search(params: state.params).listen(_onItemFetched)
..onDone(() {
emit(state.copyWith(status: SearchStatus.loaded));
});
@ -43,7 +43,7 @@ class SearchCubit extends Cubit<SearchState> {
),
);
streamSubscription =
_searchRepository.search(params: state.params).listen(_onStoryFetched)
_searchRepository.search(params: state.params).listen(_onItemFetched)
..onDone(() {
emit(state.copyWith(status: SearchStatus.loaded));
});
@ -69,6 +69,8 @@ class SearchCubit extends Cubit<SearchState> {
}
void removeFilter<T extends SearchFilter>() {
if (state.params.contains<T>() == false) return;
emit(
state.copyWith(
params: state.params.copyWithFilterRemoved<T>(),
@ -78,6 +80,16 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query);
}
void onToggled(TypeTagFilter filter) {
if (state.params.contains<TypeTagFilter>() &&
state.params.get<TypeTagFilter>() == filter) {
removeFilter<TypeTagFilter>();
} else {
removeFilter<TypeTagFilter>();
addFilter<TypeTagFilter>(filter);
}
}
void onSortToggled() {
emit(
state.copyWith(
@ -90,10 +102,44 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query);
}
void _onStoryFetched(Story story) {
void onDateTimeRangeUpdated(DateTime start, DateTime end) {
final DateTime updatedStart = start.copyWith(
second: 0,
millisecond: 0,
microsecond: 0,
);
final DateTime updatedEnd = end.copyWith(
second: 0,
millisecond: 0,
microsecond: 0,
);
final DateTime? existingStart =
state.params.get<DateTimeRangeFilter>()?.startTime;
final DateTime? existingEnd =
state.params.get<DateTimeRangeFilter>()?.endTime;
if (existingStart == updatedStart && existingEnd == updatedEnd) return;
addFilter(
DateTimeRangeFilter(
startTime: updatedStart,
endTime: updatedEnd,
),
);
}
void onPostedByChanged(String? username) {
if (username == null) {
removeFilter<PostedByFilter>();
} else {
addFilter(PostedByFilter(author: username));
}
}
void _onItemFetched(Item item) {
emit(
state.copyWith(
results: List<Story>.from(state.results)..add(story),
results: List<Item>.from(state.results)..add(item),
),
);
}

View File

@ -16,15 +16,15 @@ class SearchState extends Equatable {
SearchState.init()
: status = SearchStatus.initial,
results = <Story>[],
results = <Item>[],
params = SearchParams.init();
final List<Story> results;
final List<Item> results;
final SearchStatus status;
final SearchParams params;
SearchState copyWith({
List<Story>? results,
List<Item>? results,
SearchStatus? status,
SearchParams? params,
}) {

View File

@ -20,20 +20,20 @@ class TimeMachineCubit extends Cubit<TimeMachineState> {
final CommentCache _commentCache;
Future<void> activateTimeMachine(Comment comment) async {
emit(state.copyWith(parents: <Comment>[]));
emit(state.copyWith(ancestors: <Comment>[]));
final List<Comment> parents = <Comment>[];
final List<Comment> ancestors = <Comment>[];
Comment? parent = _commentCache.getComment(comment.parent);
parent ??= await _sembastRepository.getCachedComment(id: comment.parent);
while (parent != null) {
parents.insert(0, parent);
ancestors.insert(0, parent);
final int parentId = parent.parent;
parent = _commentCache.getComment(parentId);
parent ??= await _sembastRepository.getCachedComment(id: parentId);
}
emit(state.copyWith(parents: parents));
emit(state.copyWith(ancestors: ancestors));
}
}

View File

@ -1,18 +1,18 @@
part of 'time_machine_cubit.dart';
class TimeMachineState extends Equatable {
const TimeMachineState({required this.parents});
const TimeMachineState({required this.ancestors});
TimeMachineState.init() : parents = <Comment>[];
TimeMachineState.init() : ancestors = <Comment>[];
final List<Comment> parents;
final List<Comment> ancestors;
TimeMachineState copyWith({
List<Comment>? parents,
List<Comment>? ancestors,
}) {
return TimeMachineState(parents: parents ?? this.parents);
return TimeMachineState(ancestors: ancestors ?? this.ancestors);
}
@override
List<Object?> get props => <Object?>[parents];
List<Object?> get props => <Object?>[ancestors];
}

View File

@ -16,7 +16,7 @@ class UserCubit extends Cubit<UserState> {
void init({required String userId}) {
emit(state.copyWith(status: UserStatus.loading));
_storiesRepository.fetchUserBy(userId: userId).then((User user) {
_storiesRepository.fetchUser(id: userId).then((User user) {
emit(state.copyWith(user: user, status: UserStatus.loaded));
}).onError((_, __) {
emit(state.copyWith(status: UserStatus.failure));

View File

@ -2,17 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:share_plus/share_plus.dart';
extension StateExtension on State {
@ -59,11 +56,11 @@ extension StateExtension on State {
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
showModalBottomSheet<MenuAction>(
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return MorePopupMenu(
item: item,
isBlocked: isBlocked,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
);
},
@ -74,6 +71,9 @@ extension StateExtension on State {
break;
case MenuAction.downvote:
break;
case MenuAction.fav:
onFavTapped(item);
break;
case MenuAction.share:
onShareTapped(item, rect);
break;
@ -90,24 +90,13 @@ extension StateExtension on State {
});
}
Future<void> onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
await locator
.get<StoriesRepository>()
.fetchItemBy(id: id)
.then((Item? item) {
if (mounted) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
}
});
void onFavTapped(Item item) {
final FavCubit favCubit = context.read<FavCubit>();
final bool isFav = favCubit.state.favIds.contains(item.id);
if (isFav) {
favCubit.removeFav(item.id);
} else {
LinkUtil.launch(link);
favCubit.addFav(item.id);
}
}
@ -231,17 +220,11 @@ extension StateExtension on State {
}
void onLoginTapped() {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return LoginDialog(
usernameController: usernameController,
passwordController: passwordController,
showSnackBar: showSnackBar,
);
return const LoginDialog();
},
);
}

View File

@ -1,4 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
import 'package:hacki/utils/utils.dart';
extension WidgetModifier on Widget {
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
@ -7,4 +11,62 @@ extension WidgetModifier on Widget {
child: this,
);
}
Widget contextMenuBuilder(
BuildContext context,
EditableTextState editableTextState, {
required Item item,
}) {
final int start = editableTextState.textEditingValue.selection.base.offset;
final int end = editableTextState.textEditingValue.selection.end;
final List<ContextMenuButtonItem> items = <ContextMenuButtonItem>[
...editableTextState.contextMenuButtonItems,
];
if (start != -1 && end != -1) {
String selectedText = item.text.substring(start, end);
if (item is Buildable) {
final Iterable<EmphasisElement> emphasisElements =
(item as Buildable).elements.whereType<EmphasisElement>();
int count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start + count * 2).clamp(0, item.text.length);
final int e = (end + count * 2).clamp(0, item.text.length);
selectedText = item.text.substring(s, e);
count++;
}
count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start - count * 2).clamp(0, item.text.length);
final int e = (end - count * 2).clamp(0, item.text.length);
selectedText = item.text.substring(s, e);
count++;
}
}
items.addAll(<ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () => LinkUtil.launch(
'''${Constants.wikipediaLink}$selectedText''',
),
label: 'Wikipedia',
),
ContextMenuButtonItem(
onPressed: () => LinkUtil.launch(
'''${Constants.wiktionaryLink}$selectedText''',
),
label: 'Wiktionary',
),
]);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: items,
);
}
}

View File

@ -128,6 +128,9 @@ Future<void> main({bool testing = false}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final bool trueDarkMode =
prefs.getBool(const TrueDarkModePreference().key) ?? false;
final Font font = Font.values.elementAt(
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
);
Bloc.observer = CustomBlocObserver();
@ -137,6 +140,7 @@ Future<void> main({bool testing = false}) async {
HackiApp(
savedThemeMode: savedThemeMode,
trueDarkMode: trueDarkMode,
font: font,
),
);
}
@ -146,9 +150,11 @@ class HackiApp extends StatelessWidget {
super.key,
this.savedThemeMode,
required this.trueDarkMode,
required this.font,
});
final AdaptiveThemeMode? savedThemeMode;
final Font font;
final bool trueDarkMode;
static final GlobalKey<NavigatorState> navigatorKey =
@ -227,11 +233,13 @@ class HackiApp extends StatelessWidget {
child: AdaptiveTheme(
light: ThemeData(
primarySwatch: Palette.orange,
fontFamily: font.name,
),
dark: ThemeData(
brightness: Brightness.dark,
primarySwatch: Palette.orange,
canvasColor: trueDarkMode ? Palette.black : null,
fontFamily: font.name,
),
initial: savedThemeMode ?? AdaptiveThemeMode.system,
builder: (ThemeData theme, ThemeData darkTheme) {
@ -239,6 +247,7 @@ class HackiApp extends StatelessWidget {
brightness: Brightness.dark,
primarySwatch: Palette.orange,
canvasColor: Palette.black,
fontFamily: font.name,
);
return FutureBuilder<AdaptiveThemeMode?>(
future: AdaptiveTheme.getThemeMode(),

10
lib/models/font.dart Normal file
View File

@ -0,0 +1,10 @@
enum Font {
roboto('Roboto'),
robotoSlab('Roboto Slab'),
ubuntu('Ubuntu'),
ubuntuMono('Ubuntu Mono');
const Font(this.label);
final String label;
}

View File

@ -0,0 +1,5 @@
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
mixin Buildable {
List<LinkifyElement> get elements;
}

View File

@ -1,8 +1,10 @@
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/models/comment.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/models/item/buildable.dart';
import 'package:hacki/models/item/comment.dart';
import 'package:linkify/linkify.dart';
class BuildableComment extends Comment {
/// [BuildableComment] is a subtype of [Comment] which stores
/// the corresponding [LinkifyElement] for faster widget building.
class BuildableComment extends Comment with Buildable {
BuildableComment({
required super.id,
required super.time,
@ -31,5 +33,6 @@ class BuildableComment extends Comment {
level: comment.level,
);
@override
final List<LinkifyElement> elements;
}

View File

@ -0,0 +1,46 @@
import 'package:hacki/models/item/buildable.dart';
import 'package:hacki/models/item/story.dart';
import 'package:linkify/linkify.dart';
/// [BuildableStory] is a subtype of [Story] which stores
/// the corresponding [LinkifyElement] for faster widget building.
class BuildableStory extends Story with Buildable {
const BuildableStory({
required super.id,
required super.time,
required super.score,
required super.by,
required super.text,
required super.kids,
required super.descendants,
required super.title,
required super.type,
required super.url,
required super.parts,
required this.elements,
});
BuildableStory.fromStory(Story story, {required this.elements})
: super(
id: story.id,
time: story.time,
score: story.score,
by: story.by,
text: story.text,
kids: story.kids,
descendants: story.descendants,
title: story.title,
type: story.type,
url: story.url,
parts: story.parts,
);
BuildableStory.fromTitleOnlyStory(Story story)
: this.fromStory(
story,
elements: const <LinkifyElement>[],
);
@override
final List<LinkifyElement> elements;
}

View File

@ -1,4 +1,4 @@
import 'package:hacki/models/item.dart';
import 'package:hacki/models/item/item.dart';
class Comment extends Item {
Comment({
@ -41,38 +41,6 @@ class Comment extends Item {
);
}
@override
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'time': time,
'by': by,
'text': text,
'kids': kids,
'parent': parent,
'deleted': deleted,
'dead': dead,
'score': score,
'level': level,
};
@override
bool? get stringify => false;
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
}

View File

@ -1,6 +1,17 @@
import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.dart';
import 'package:hacki/models/item/comment.dart';
import 'package:hacki/models/item/poll_option.dart';
import 'package:hacki/models/item/story.dart';
export 'buildable.dart';
export 'buildable_comment.dart';
export 'buildable_story.dart';
export 'comment.dart';
export 'poll_option.dart';
export 'story.dart';
/// [Item] is the base type of [Story], [Comment] and [PollOption].
class Item extends Equatable {
const Item({
required this.id,
@ -97,6 +108,7 @@ class Item extends Equatable {
'deleted': deleted,
'type': type,
'parts': parts,
'parent': parent,
};
}

View File

@ -1,6 +1,6 @@
import 'dart:convert';
import 'package:hacki/models/item.dart';
import 'package:hacki/models/item/item.dart';
class PollOption extends Item {
const PollOption({
@ -52,19 +52,7 @@ class PollOption extends Item {
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'descendants': descendants,
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
'parts': parts,
...super.toJson(),
'ratio': ratio,
};
}
@ -75,22 +63,4 @@ class PollOption extends Item {
const JsonEncoder.withIndent(' ').convert(this);
return 'PollOption $prettyString';
}
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
}

View File

@ -1,5 +1,5 @@
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart';
import 'package:hacki/models/item/item.dart';
class Story extends Item {
const Story({
@ -54,25 +54,6 @@ class Story extends Item {
return authority;
}
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'descendants': descendants,
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
'parts': parts,
};
}
@override
String toString() {
// final String prettyString =
@ -80,23 +61,4 @@ class Story extends Item {
// return 'Story $prettyString';
return 'Story $id';
}
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
text,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
}

View File

@ -1,13 +1,10 @@
export 'buildable_comment.dart';
export 'comment.dart';
export 'comments_order.dart';
export 'fetch_mode.dart';
export 'font.dart';
export 'font_size.dart';
export 'item.dart';
export 'poll_option.dart';
export 'item/item.dart';
export 'post_data.dart';
export 'preference.dart';
export 'search_params.dart';
export 'story.dart';
export 'story_type.dart';
export 'user.dart';

View File

@ -20,6 +20,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
// Order of these first four preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontPreference(),
FontSizePreference(),
TabOrderPreference(),
// Order of items below matters and
@ -65,6 +66,7 @@ const bool _collapseModeDefaultValue = true;
final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final int _fontSizeDefaultValue = FontSize.regular.index;
final int _fontDefaultValue = Font.roboto.index;
final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values);
@ -325,6 +327,21 @@ class CommentsOrderPreference extends IntPreference {
String get title => 'Default comments order';
}
class FontPreference extends IntPreference {
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
@override
FontPreference copyWith({required int? val}) {
return FontPreference(val: val);
}
@override
String get key => 'font';
@override
String get title => 'Default font';
}
class FontSizePreference extends IntPreference {
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);

View File

@ -8,8 +8,19 @@ abstract class NumericFilter extends SearchFilter {}
abstract class TagFilter extends SearchFilter {}
abstract class TypeTagFilter extends TagFilter {
static List<TypeTagFilter> all = <TypeTagFilter>[
const StoryFilter(),
const PollFilter(),
const CommentFilter(),
const FrontPageFilter(),
const AskHnFilter(),
const ShowHnFilter(),
];
}
class DateTimeRangeFilter implements NumericFilter {
DateTimeRangeFilter({
const DateTimeRangeFilter({
this.startTime,
this.endTime,
});
@ -37,7 +48,7 @@ class DateTimeRangeFilter implements NumericFilter {
}
class PostedByFilter implements TagFilter {
PostedByFilter({required this.author});
const PostedByFilter({required this.author});
final String author;
@ -47,8 +58,8 @@ class PostedByFilter implements TagFilter {
}
}
class FrontPageFilter implements TagFilter {
FrontPageFilter();
class FrontPageFilter implements TypeTagFilter {
const FrontPageFilter();
@override
String get query {
@ -56,8 +67,8 @@ class FrontPageFilter implements TagFilter {
}
}
class ShowHnFilter implements TagFilter {
ShowHnFilter();
class ShowHnFilter implements TypeTagFilter {
const ShowHnFilter();
@override
String get query {
@ -65,8 +76,8 @@ class ShowHnFilter implements TagFilter {
}
}
class AskHnFilter implements TagFilter {
AskHnFilter();
class AskHnFilter implements TypeTagFilter {
const AskHnFilter();
@override
String get query {
@ -74,8 +85,8 @@ class AskHnFilter implements TagFilter {
}
}
class PollFilter implements TagFilter {
PollFilter();
class PollFilter implements TypeTagFilter {
const PollFilter();
@override
String get query {
@ -83,8 +94,8 @@ class PollFilter implements TagFilter {
}
}
class StoryFilter implements TagFilter {
StoryFilter();
class StoryFilter implements TypeTagFilter {
const StoryFilter();
@override
String get query {
@ -92,8 +103,17 @@ class StoryFilter implements TagFilter {
}
}
class CommentFilter implements TypeTagFilter {
const CommentFilter();
@override
String get query {
return 'comment';
}
}
class CombinedFilter implements TagFilter {
CombinedFilter({required this.filters});
const CombinedFilter({required this.filters});
final List<TagFilter> filters;

View File

@ -70,7 +70,6 @@ class SearchParams extends Equatable {
filters.whereType<NumericFilter>();
final List<TagFilter> tagFilters = <TagFilter>[
...filters.whereType<TagFilter>(),
CombinedFilter(filters: <TagFilter>[StoryFilter(), PollFilter()]),
];
if (numericFilters.isNotEmpty) {

View File

@ -2,10 +2,16 @@ import 'dart:async';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/post_repository.dart';
import 'package:hacki/repositories/postable_repository.dart';
import 'package:hacki/repositories/preference_repository.dart';
import 'package:logger/logger.dart';
/// [AuthRepository] if for logging user in/out and performing actions
/// that require a logged in user such as [flag], [favorite], [upvote],
/// and [downvote].
///
/// For posting actions such as posting a comment, see [PostRepository].
class AuthRepository extends PostableRepository {
AuthRepository({
super.dio,

View File

@ -4,9 +4,14 @@ import 'package:hacki/models/models.dart';
import 'package:hive/hive.dart';
import 'package:http/http.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
/// [OfflineRepository] is for storing stories and comments for offline reading.
/// It's using [Hive] as its database which is being stored in temp directory.
/// [OfflineRepository] is for storing [Story] and [Comment] for
/// offline reading.
///
/// [Hive] is used as its database and is being stored in the temporary
/// directory assigned by host system which you can retrieve
/// by calling [getTemporaryDirectory].
class OfflineRepository {
OfflineRepository({
Future<Box<List<int>>>? storyIdBox,
@ -36,7 +41,7 @@ class OfflineRepository {
_storyBox.then((Box<Map<dynamic, dynamic>> box) => box.isNotEmpty);
Future<void> cacheStoryIds({
required StoryType of,
required StoryType type,
required List<int> ids,
}) async {
late final Box<List<int>> box;
@ -49,7 +54,7 @@ class OfflineRepository {
box = await _storyIdBox;
}
return box.put(of.name, ids);
return box.put(type.name, ids);
}
Future<void> cacheStory({required Story story}) async {
@ -103,10 +108,10 @@ class OfflineRepository {
}
}
Future<List<int>> getCachedStoryIds({required StoryType of}) async {
Future<List<int>> getCachedStoryIds({required StoryType type}) async {
try {
final Box<List<int>> box = await _storyIdBox;
final List<int>? ids = box.get(of.name);
final List<int>? ids = box.get(type.name);
return ids ?? <int>[];
} catch (_) {
_logger.e(_);

View File

@ -7,6 +7,7 @@ import 'package:hacki/repositories/postable_repository.dart';
import 'package:hacki/repositories/preference_repository.dart';
import 'package:hacki/utils/utils.dart';
/// [PostRepository] is for posting contents to Hacker News.
class PostRepository extends PostableRepository {
PostRepository({super.dio, PreferenceRepository? storageRepository})
: _preferenceRepository =

View File

@ -3,8 +3,12 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/auth_repository.dart';
import 'package:hacki/repositories/post_repository.dart';
import 'package:hacki/utils/service_exception.dart';
/// [PostableRepository] is solely for hosting functionalities shared between
/// [AuthRepository] and [PostRepository].
class PostableRepository {
PostableRepository({
Dio? dio,

View File

@ -7,6 +7,7 @@ import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:synced_shared_preferences/synced_shared_preferences.dart';
/// [PreferenceRepository] is for storing user preferences.
class PreferenceRepository {
PreferenceRepository({
SyncedSharedPreferences? syncedPrefs,

View File

@ -3,6 +3,9 @@ import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart';
/// [SearchRepository] is for searching contents on Hacker News.
///
/// You can learn about the search API at https://hn.algolia.com/api.
class SearchRepository {
SearchRepository({Dio? dio}) : _dio = dio ?? Dio();
@ -10,7 +13,7 @@ class SearchRepository {
final Dio _dio;
Stream<Story> search({
Stream<Item> search({
required SearchParams params,
}) async* {
final String url = '$_baseUrl${params.filteredQuery}';
@ -33,37 +36,53 @@ class SearchRepository {
final int score = hit['points'] as int? ?? 0;
final int descendants = hit['num_comments'] as int? ?? 0;
// Getting rid of comments, only keeping stories for convenience.
// Don't judge me.
if (title.isEmpty) {
continue;
}
final String url = hit['url'] as String? ?? '';
final String type =
title.toLowerCase().contains('poll:') ? 'poll' : 'story';
final String text = hit['story_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final int id = int.parse(hit['objectID'] as String? ?? '0');
final Story story = Story(
descendants: descendants,
id: id,
score: score,
time: createdAt,
by: by,
title: title,
text: parsedText,
url: url,
type: type,
// response doesn't contain kids and parts.
kids: const <int>[],
parts: const <int>[],
);
yield story;
if (title.isEmpty) {
final String text = hit['comment_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final int parentId = hit['parent_id'] as int? ?? 0;
final Comment comment = Comment(
id: id,
score: score,
time: createdAt,
by: by,
text: parsedText,
kids: const <int>[],
parent: parentId,
dead: false,
deleted: false,
level: 0,
);
yield comment;
} else {
final String text = hit['story_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final Story story = Story(
descendants: descendants,
id: id,
score: score,
time: createdAt,
by: by,
title: title,
text: parsedText,
url: url,
type: type,
// response doesn't contain kids and parts.
kids: const <int>[],
parts: const <int>[],
);
yield story;
}
}
return;
}

View File

@ -7,7 +7,10 @@ import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart';
/// [SembastRepository] is for storing stories and comments for faster loading.
/// It's using Sembast as its database which is being stored in doc directory.
///
/// Sembast [Database] is used as its database and is being stored in the
/// documents directory assigned by host system which you can retrieve
/// by calling [getApplicationDocumentsDirectory].
class SembastRepository {
SembastRepository({Database? database}) {
if (database == null) {

View File

@ -4,6 +4,11 @@ import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:tuple/tuple.dart';
/// [StoriesRepository] is for fetching
/// [Item] such as [Story], [PollOption], [Comment] or [User].
///
/// You can learn more about the Hacker News API at
/// https://github.com/HackerNews/API.
class StoriesRepository {
StoriesRepository({
FirebaseClient? firebaseClient,
@ -12,9 +17,66 @@ class StoriesRepository {
final FirebaseClient _firebaseClient;
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
Future<User> fetchUserBy({required String userId}) async {
Future<Map<String, dynamic>?> _fetchItemJson(int id) async {
return _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?));
}
Future<Map<String, dynamic>?> _fetchRawItemJson(int id) async {
return _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic value) => value as Map<String, dynamic>?);
}
/// Fetch a [Item] based on its id.
Future<Item?> fetchItem({required int id}) async {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json);
return story;
} else if (type == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
return item;
}
/// Fetch a raw [Item] based on its id.
/// The content of [Item] will not be parsed, use this function only if
/// the format of content doesn't matter, otherwise, use [fetchItem].
Future<Item?> fetchRawItem({required int id}) async {
final Item? item = await _fetchRawItemJson(id).then((dynamic val) {
if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>;
final String type = json['type'] as String;
if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json);
return story;
} else if (type == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
return item;
}
/// Fetch a [User] by its [id].
/// Hacker News uses user's username as [id].
Future<User> fetchUser({required String id}) async {
final User user = await _firebaseClient
.get('${_baseUrl}user/$userId.json')
.get('${_baseUrl}user/$id.json')
.then((dynamic val) {
final Map<String, dynamic> json = val as Map<String, dynamic>;
final User user = User.fromJson(json);
@ -24,9 +86,27 @@ class StoriesRepository {
return user;
}
Future<List<int>> fetchStoryIds({required StoryType of}) async {
/// Fetch a list of ids of [Story] or [Comment] submitted by the user.
Future<List<int>?> fetchSubmitted({required String userId}) async {
final List<int>? submitted = await _firebaseClient
.get('${_baseUrl}user/$userId.json')
.then((dynamic val) {
if (val == null) {
return null;
}
final Map<String, dynamic> json = val as Map<String, dynamic>;
final List<int> submitted =
(json['submitted'] as List<dynamic>? ?? <dynamic>[]).cast<int>();
return submitted;
});
return submitted;
}
/// Fetch ids of stories of a certain [StoryType].
Future<List<int>> fetchStoryIds({required StoryType type}) async {
final List<int> ids = await _firebaseClient
.get('$_baseUrl${of.path}.json')
.get('$_baseUrl${type.path}.json')
.then((dynamic val) {
final List<int> ids = (val as List<dynamic>).cast<int>();
return ids;
@ -35,11 +115,10 @@ class StoriesRepository {
return ids;
}
Future<Story?> fetchStoryBy(int id) async {
final Story? story = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) {
/// Fetch a [Story] based on its id.
Future<Story?> fetchStory({required int id}) async {
final Story? story =
await _fetchItemJson(id).then((Map<String, dynamic>? json) {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
@ -48,6 +127,90 @@ class StoriesRepository {
return story;
}
/// Fetch a [Comment] based on its id.
Future<Comment?> fetchComment({required int id}) async {
final Comment? comment =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
/// Fetch a raw [Comment] based on its id.
/// The content of [Comment] will not be parsed, use this function only if
/// the format of content doesn't matter, otherwise, use [fetchComment].
Future<Comment?> fetchRawComment({required int id}) async {
final Comment? comment =
await _fetchRawItemJson(id).then((dynamic val) async {
if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
/// Fetch the parent [Story] of a [Comment].
Future<Story?> fetchParentStory({required int id}) async {
Item? item;
do {
item = await fetchItem(id: item?.parent ?? id);
if (item == null) return null;
} while (item is Comment);
return item as Story;
}
/// Fetch the raw parent [Story] of a [Comment].
/// The content of [Story] will not be parsed, use this function only if
/// the format of content doesn't matter, otherwise, use [fetchParentStory].
Future<Story?> fetchRawParentStory({required int id}) async {
Item? item;
do {
item = await fetchRawItem(id: item?.parent ?? id);
if (item == null) return null;
} while (item is Comment);
return item as Story;
}
/// Fetch the parent [Story] of a [Comment] as well as
/// the list of [Comment] traversed in order to reach the parent.
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments({
required int id,
}) async {
Item? item;
final List<Comment> parentComments = <Comment>[];
do {
item = await fetchItem(id: item?.parent ?? id);
if (item is Comment) {
parentComments.add(item);
}
if (item == null) return null;
} while (item is Comment);
for (int i = 0; i < parentComments.length; i++) {
parentComments[i] =
parentComments[i].copyWith(level: parentComments.length - i - 1);
}
return Tuple2<Story, List<Comment>>(
item as Story,
parentComments.reversed.toList(),
);
}
/// Fetch a list of [Comment] based on ids and return results
/// using a stream.
Stream<Comment> fetchCommentsStream({
required List<int> ids,
int level = 0,
@ -56,10 +219,8 @@ class StoriesRepository {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
comment ??=
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
@ -73,6 +234,8 @@ class StoriesRepository {
return;
}
/// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream.
Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids,
int level = 0,
@ -81,10 +244,8 @@ class StoriesRepository {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
comment ??=
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
@ -104,19 +265,19 @@ class StoriesRepository {
return;
}
/// Fetch a list of [Item] based on ids and return results
/// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* {
for (final int id in ids) {
final Item? item = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job') {
final Story story = Story.fromJson(json);
return story;
} else if (json['type'] == 'comment') {
} else if (type == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
@ -129,12 +290,12 @@ class StoriesRepository {
}
}
/// Fetch a list of [Story] based on ids and return results
/// using a stream.
Stream<Story> fetchStoriesStream({required List<int> ids}) async* {
for (final int id in ids) {
final Story? story = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
final Story? story =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
@ -146,11 +307,12 @@ class StoriesRepository {
}
}
/// Fetch a list of [PollOption] based on ids and return results
/// using a stream.
Stream<PollOption> fetchPollOptionsStream({required List<int> ids}) async* {
for (final int id in ids) {
final PollOption? option = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) async {
final PollOption? option =
await _fetchRawItemJson(id).then((dynamic json) async {
if (json == null) return null;
final PollOption option =
PollOption.fromJson(json as Map<String, dynamic>);
@ -163,143 +325,10 @@ class StoriesRepository {
}
}
Future<Comment?> fetchCommentBy({required int id}) async {
final Comment? comment = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
Future<Comment?> fetchRawCommentBy({required int id}) async {
final Comment? comment = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic val) async {
if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
Future<Item?> fetchItemBy({required int id}) async {
final Item? item = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json);
return story;
} else if (json['type'] == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
return item;
}
Future<Item?> fetchRawItemBy({required int id}) async {
final Item? item = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic val) {
if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>;
final String type = json['type'] as String;
if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json);
return story;
} else if (json['type'] == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
return item;
}
Future<List<int>?> fetchSubmitted({required String of}) async {
final List<int>? submitted = await _firebaseClient
.get('${_baseUrl}user/$of.json')
.then((dynamic val) {
if (val == null) {
return null;
}
final Map<String, dynamic> json = val as Map<String, dynamic>;
final List<int> submitted =
(json['submitted'] as List<dynamic>? ?? <dynamic>[]).cast<int>();
return submitted;
});
return submitted;
}
Future<Story?> fetchParentStory({required int id}) async {
Item? item;
do {
item = await fetchItemBy(id: item?.parent ?? id);
if (item == null) return null;
} while (item is Comment);
return item as Story;
}
Future<Story?> fetchRawParentStory({required int id}) async {
Item? item;
do {
item = await fetchRawItemBy(id: item?.parent ?? id);
if (item == null) return null;
} while (item is Comment);
return item as Story;
}
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments({
required int id,
}) async {
Item? item;
final List<Comment> 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);
for (int i = 0; i < parentComments.length; i++) {
parentComments[i] =
parentComments[i].copyWith(level: parentComments.length - i - 1);
}
return Tuple2<Story, List<Comment>>(
item as Story,
parentComments.reversed.toList(),
);
}
/// Fetch a list of [Comment] based on ids recursively.
Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* {
for (final int id in ids) {
final Comment? comment = await fetchCommentBy(id: id);
final Comment? comment = await fetchComment(id: id);
if (comment != null) {
yield comment;
yield* fetchAllChildrenComments(ids: comment.kids);
@ -307,7 +336,10 @@ class StoriesRepository {
}
}
Future<Map<String, dynamic>?> _parseJson(Map<String, dynamic>? json) async {
/// Parse the json of an [Item] by removing useless HTML tags.
static Future<Map<String, dynamic>?> _parseJson(
Map<String, dynamic>? json,
) async {
if (json == null) return null;
final String text = json['text'] as String? ?? '';
final String parsedText = await compute<String, String>(

View File

@ -92,14 +92,12 @@ class _HomeScreenState extends State<HomeScreen>
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (!isTesting) {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featureLogIn,
},
);
}
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featureLogIn,
},
);
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -278,7 +276,7 @@ class _HomeScreenState extends State<HomeScreen>
final int? id = event.itemId;
if (id != null) {
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
locator.get<StoriesRepository>().fetchItem(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
goToItemScreen(
@ -298,7 +296,7 @@ class _HomeScreenState extends State<HomeScreen>
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.fetchStory(id: storyId)
.then((Story? story) {
if (story == null) {
showErrorSnackBar();
@ -323,7 +321,7 @@ class _HomeScreenState extends State<HomeScreen>
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.fetchStory(id: storyId)
.then((Story? story) {
if (story == null) {
showErrorSnackBar();

View File

@ -1,5 +1,3 @@
// ignore_for_file: comment_references
import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
@ -11,8 +9,8 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
@ -33,7 +31,7 @@ class ItemScreenArgs extends Equatable {
final List<Comment>? targetComments;
/// when a user is trying to view a sub-thread from a main thread, we don't
/// need to fetch comments from [StoryRepository] since we have some, if not
/// need to fetch comments from [StoriesRepository] since we have some, if not
/// all, comments cached in [CommentCache].
final bool useCommentCache;
@ -76,7 +74,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache,
),
),
@ -118,7 +116,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
targetAncestors: args.targetComments,
),
),
],
@ -175,16 +173,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (!isTesting) {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
}
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -293,8 +289,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
),
@ -318,8 +312,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
splitViewEnabled: state.enabled,
expanded: state.expanded,
onZoomTap:
@ -359,8 +351,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
Theme.of(context).canvasColor.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
@ -373,8 +363,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
bottomSheet: ReplyBox(
@ -396,15 +384,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
);
}
Future<bool> onFeatureDiscoveryDismissed() {
featureDiscoveryDismissThrottle.run(() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).clearSnackBars();
showSnackBar(content: 'Tap on icon to continue');
});
return Future<bool>.value(false);
}
void onFontSizeTapped() {
const Offset offset = Offset.zero;
final RenderBox overlay =
@ -470,7 +449,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
children: <Widget>[
ListTile(
leading: const Icon(Icons.av_timer),
title: const Text('View parents'),
title: const Text('View ancestors'),
onTap: () {
Navigator.pop(context);
onTimeMachineActivated(comment);
@ -514,7 +493,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
size: size,
deviceType: deviceType,
widthFactor: widthFactor,
onStoryLinkTapped: onStoryLinkTapped,
);
},
);

View File

@ -1,6 +1,7 @@
enum MenuAction {
upvote,
downvote,
fav,
share,
block,
flag,

View File

@ -11,8 +11,6 @@ class CustomAppBar extends AppBar {
required ScrollController scrollController,
required Item item,
required Color super.backgroundColor,
required Future<bool> Function() onBackgroundTap,
required Future<bool> Function() onDismiss,
required VoidCallback onFontSizeTap,
required GlobalKey fontSizeIconButtonKey,
bool splitViewEnabled = false,
@ -41,26 +39,26 @@ class CustomAppBar extends AppBar {
),
IconButton(
key: fontSizeIconButtonKey,
icon: const Icon(
Icons.format_size,
icon: Text(
String.fromCharCode(FeatherIcons.type.codePoint),
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
),
),
onPressed: onFontSizeTap,
),
if (item is Story)
PinIconButton(
story: item,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
),
FavIconButton(
storyId: item.id,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
),
LinkIconButton(
storyId: item.id,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
),
],
);

View File

@ -1,24 +1,18 @@
import 'dart:async';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class FavIconButton extends StatelessWidget {
const FavIconButton({
super.key,
required this.storyId,
required this.onBackgroundTap,
required this.onDismiss,
});
final int storyId;
final Future<bool> Function() onBackgroundTap;
final Future<bool> Function() onDismiss;
@override
Widget build(BuildContext context) {
@ -27,15 +21,7 @@ class FavIconButton extends StatelessWidget {
final bool isFav = favState.favIds.contains(storyId);
return IconButton(
tooltip: 'Add to favorites',
icon: DescribedFeatureOverlay(
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
icon: CustomDescribedFeatureOverlay(
tapTarget: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: Palette.white,

View File

@ -1,9 +1,6 @@
import 'dart:async';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
@ -11,40 +8,28 @@ class LinkIconButton extends StatelessWidget {
const LinkIconButton({
super.key,
required this.storyId,
required this.onBackgroundTap,
required this.onDismiss,
});
final int storyId;
final Future<bool> Function() onBackgroundTap;
final Future<bool> Function() onDismiss;
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Open this story in browser',
icon: DescribedFeatureOverlay(
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
icon: const CustomDescribedFeatureOverlay(
tapTarget: Icon(
Icons.stream,
color: Palette.white,
),
featureId: Constants.featureOpenStoryInWebView,
title: const Text('Open in Browser'),
description: const Text(
title: Text('Open in Browser'),
description: Text(
'Want more than just reading and replying? '
'You can tap here to open this story in a '
'browser.',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: const Icon(
child: Icon(
Icons.stream,
),
),

View File

@ -2,25 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class LoginDialog extends StatelessWidget {
const LoginDialog({
super.key,
required this.usernameController,
required this.passwordController,
required this.showSnackBar,
});
class LoginDialog extends StatefulWidget {
const LoginDialog({super.key});
final TextEditingController usernameController;
final TextEditingController passwordController;
final void Function({
required String content,
VoidCallback? action,
String? label,
}) showSnackBar;
@override
State<LoginDialog> createState() => _LoginDialogState();
}
class _LoginDialogState extends State<LoginDialog> {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
@ -90,9 +86,10 @@ class LoginDialog extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt18,
right: Dimens.pt6,
),
child: Text(
Constants.errorMessage,
Constants.loginErrorMessage,
style: const TextStyle(
color: Palette.grey,
fontSize: TextDimens.pt12,

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
@ -26,8 +25,6 @@ class MainView extends StatelessWidget {
required this.topPadding,
required this.splitViewEnabled,
required this.onMoreTapped,
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
});
@ -39,8 +36,6 @@ class MainView extends StatelessWidget {
final double topPadding;
final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
static const int _loadingIndicatorOpacityAnimationDuration = 300;
@ -124,8 +119,6 @@ class MainView extends StatelessWidget {
topPadding: topPadding,
splitViewEnabled: splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
);
} else if (index == state.comments.length + 1) {
@ -150,8 +143,6 @@ class MainView extends StatelessWidget {
child: CommentTile(
comment: comment,
level: comment.level,
myUsername:
authState.isLoggedIn ? authState.username : null,
opUsername: state.item.by,
fetchMode: state.fetchMode,
onReplyTapped: (Comment cmt) {
@ -178,7 +169,6 @@ class MainView extends StatelessWidget {
focusNode.requestFocus();
},
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onRightMoreTapped: onRightMoreTapped,
),
);
@ -225,8 +215,6 @@ class _ParentItemSection extends StatelessWidget {
required this.topPadding,
required this.splitViewEnabled,
required this.onMoreTapped,
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
});
@ -239,8 +227,6 @@ class _ParentItemSection extends StatelessWidget {
final double topPadding;
final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
@override
@ -340,15 +326,11 @@ class _ParentItemSection extends StatelessWidget {
bottom: Dimens.pt12,
top: Dimens.pt12,
),
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
fontSize: prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
@ -359,10 +341,7 @@ class _ParentItemSection extends StatelessWidget {
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
fontSize: prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
@ -374,15 +353,17 @@ class _ParentItemSection extends StatelessWidget {
''' (${(state.item as Story).readableUrl})''',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: MediaQuery.of(
context,
).textScaleFactor *
(prefState.fontSize.fontSize - 4),
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
),
),
],
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
@ -391,36 +372,15 @@ class _ParentItemSection extends StatelessWidget {
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: SelectableLinkify(
text: state.item.text,
style: TextStyle(
fontSize: MediaQuery.of(context).textScaleFactor *
context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context).textScaleFactor *
context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
color: Palette.orange,
child: ItemText(
item: state.item,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
),
],
@ -431,9 +391,7 @@ class _ParentItemSection extends StatelessWidget {
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: PollView(
onLoginTapped: onLoginTapped,
),
child: const PollView(),
),
],
),

View File

@ -1,12 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
@ -15,15 +18,24 @@ class MorePopupMenu extends StatelessWidget {
super.key,
required this.item,
required this.isBlocked,
required this.onStoryLinkTapped,
required this.onLoginTapped,
});
final Item item;
final bool isBlocked;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
static double? _cachedStoryHeight;
static double? _cachedCommentHeight;
static double get storyHeight {
return _cachedStoryHeight ??= Platform.isIOS ? 500 : 530;
}
static double get commentHeight {
return _cachedCommentHeight ??= Platform.isIOS ? 480 : 520;
}
@override
Widget build(BuildContext context) {
return BlocProvider<VoteCubit>(
@ -68,7 +80,7 @@ class MorePopupMenu extends StatelessWidget {
final bool upvoted = voteState.vote == Vote.up;
final bool downvoted = voteState.vote == Vote.down;
return Container(
height: item is Comment ? 430 : 450,
height: item is Comment ? commentHeight : storyHeight,
color: Theme.of(context).canvasColor,
child: Material(
color: Palette.transparent,
@ -88,6 +100,7 @@ class MorePopupMenu extends StatelessWidget {
state.user.description,
),
onTap: () {
Navigator.pop(context);
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
@ -112,15 +125,19 @@ class MorePopupMenu extends StatelessWidget {
linkStyle: const TextStyle(
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
onOpen: (LinkableElement link) =>
LinkUtil.launch,
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
onSearchUserTapped(context);
},
child: const Text(
'Search',
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
@ -163,6 +180,24 @@ class MorePopupMenu extends StatelessWidget {
),
onTap: context.read<VoteCubit>().downvote,
),
BlocBuilder<FavCubit, FavState>(
builder: (BuildContext context, FavState state) {
final bool isFav = state.favIds.contains(item.id);
return ListTile(
leading: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Palette.orange : null,
),
title: Text(
isFav ? 'Unfavorite' : 'Favorite',
),
onTap: () => Navigator.pop(
context,
MenuAction.fav,
),
);
},
),
ListTile(
leading: const Icon(FeatherIcons.share),
title: const Text(
@ -213,4 +248,45 @@ class MorePopupMenu extends StatelessWidget {
),
);
}
void onSearchUserTapped(BuildContext context) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return BlocProvider<SearchCubit>(
create: (_) => SearchCubit()
..addFilter(
PostedByFilter(
author: item.by,
),
),
child: Container(
height: MediaQuery.of(context).size.height - Dimens.pt120,
color: Theme.of(context).canvasColor,
margin: const EdgeInsets.only(top: Dimens.pt12),
child: Material(
child: Column(
children: <Widget>[
Container(
height: Dimens.pt4,
width: Dimens.pt24,
decoration: BoxDecoration(
color: Palette.grey,
borderRadius: BorderRadius.circular(Dimens.pt16),
),
),
const Expanded(
child: SearchScreen(
fromUserDialog: true,
),
)
],
),
),
),
);
},
);
}
}

View File

@ -1,26 +1,21 @@
import 'dart:async';
import 'dart:math';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class PinIconButton extends StatelessWidget {
const PinIconButton({
super.key,
required this.story,
required this.onBackgroundTap,
required this.onDismiss,
});
final Story story;
final Future<bool> Function() onBackgroundTap;
final Future<bool> Function() onDismiss;
@override
Widget build(BuildContext context) {
@ -33,15 +28,7 @@ class PinIconButton extends StatelessWidget {
offset: const Offset(2, 0),
child: IconButton(
tooltip: 'Pin to home screen',
icon: DescribedFeatureOverlay(
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
icon: CustomDescribedFeatureOverlay(
tapTarget: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Palette.white,

View File

@ -4,18 +4,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart';
class PollView extends StatelessWidget {
const PollView({
super.key,
required this.onLoginTapped,
});
class PollView extends StatefulWidget {
const PollView({super.key});
final VoidCallback onLoginTapped;
@override
State<PollView> createState() => _PollViewState();
}
class _PollViewState extends State<PollView> {
@override
Widget build(BuildContext context) {
return BlocBuilder<PollCubit, PollState>(

View File

@ -3,13 +3,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart';
import 'package:hacki/models/item/item.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/link_util.dart';
class ReplyBox extends StatefulWidget {
const ReplyBox({
@ -256,6 +255,8 @@ class _ReplyBoxState extends State<ReplyBox> {
void showTextPopup() {
final Item? replyingTo = context.read<EditCubit>().state.replyingTo;
if (replyingTo == null) return;
showDialog<void>(
context: context,
builder: (_) {
@ -280,37 +281,49 @@ class _ReplyBoxState extends State<ReplyBox> {
child: Row(
children: <Widget>[
Text(
replyingTo?.by ?? '',
style: const TextStyle(color: Palette.grey),
replyingTo.by,
style: const TextStyle(
fontSize: TextDimens.pt14,
color: Palette.grey,
),
),
const Spacer(),
if (replyingTo != null)
TextButton(
child: const Text('View thread'),
onPressed: () {
HapticFeedback.lightImpact();
setState(() {
expanded = false;
});
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName ||
route.isFirst,
);
goToItemScreen(
args: ItemScreenArgs(
item: replyingTo,
useCommentCache: true,
),
forceNewScreen: true,
);
},
),
TextButton(
child: const Text('Copy all'),
child: const Text(
'View thread',
style: TextStyle(
fontSize: TextDimens.pt14,
),
),
onPressed: () {
HapticFeedback.lightImpact();
setState(() {
expanded = false;
});
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName ||
route.isFirst,
);
goToItemScreen(
args: ItemScreenArgs(
item: replyingTo,
useCommentCache: true,
),
forceNewScreen: true,
);
},
),
TextButton(
child: const Text(
'Copy all',
style: TextStyle(
fontSize: TextDimens.pt14,
),
),
onPressed: () => FlutterClipboard.copy(
replyingTo?.text ?? '',
replyingTo.text,
).then((_) => HapticFeedback.selectionClick()),
),
IconButton(
@ -334,16 +347,8 @@ class _ReplyBoxState extends State<ReplyBox> {
top: Dimens.pt6,
),
child: SingleChildScrollView(
child: SelectableLinkify(
scrollPhysics: const NeverScrollableScrollPhysics(),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context).textScaleFactor *
TextDimens.pt15,
color: Palette.orange,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url),
text: replyingTo?.text ?? '',
child: ItemText(
item: replyingTo,
),
),
),

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
@ -14,14 +13,12 @@ class TimeMachineDialog extends StatelessWidget {
required this.size,
required this.deviceType,
required this.widthFactor,
required this.onStoryLinkTapped,
});
final Comment comment;
final Size size;
final DeviceScreenType deviceType;
final double widthFactor;
final void Function(String) onStoryLinkTapped;
@override
Widget build(BuildContext context) {
@ -52,7 +49,7 @@ class TimeMachineDialog extends StatelessWidget {
const SizedBox(
width: Dimens.pt8,
),
const Text('Parents:'),
const Text('Ancestors:'),
const Spacer(),
IconButton(
icon: const Icon(
@ -67,12 +64,10 @@ class TimeMachineDialog extends StatelessWidget {
Expanded(
child: ListView(
children: <Widget>[
for (final Comment c in state.parents) ...<Widget>[
for (final Comment c
in state.ancestors) ...<Widget>[
CommentTile(
comment: c,
myUsername:
context.read<AuthBloc>().state.username,
onStoryLinkTapped: onStoryLinkTapped,
actionable: false,
fetchMode: FetchMode.eager,
),

View File

@ -35,15 +35,6 @@ class _ProfileScreenState extends State<ProfileScreen>
PageType pageType = PageType.notification;
final List<String> magicWords = <String>[
'to be a lord.',
'to conquer the world.',
'to be over the rainbow!',
'to bless humanity with long-lasting peace.',
'to save the world',
'to infinity and beyond!',
];
@override
void dispose() {
super.dispose();
@ -57,7 +48,6 @@ class _ProfileScreenState extends State<ProfileScreen>
@override
Widget build(BuildContext context) {
super.build(context);
final String magicWord = (magicWords..shuffle()).first;
return BlocBuilder<AuthBloc, AuthState>(
builder: (BuildContext context, AuthState authState) {
return BlocConsumer<NotificationCubit, NotificationState>(
@ -239,9 +229,8 @@ class _ProfileScreenState extends State<ProfileScreen>
),
Settings(
authState: authState,
magicWord: magicWord,
magicWord: Constants.magicWord,
pageType: pageType,
onLoginTapped: onLoginTapped,
),
Align(
alignment: Alignment.topLeft,
@ -384,193 +373,6 @@ class _ProfileScreenState extends State<ProfileScreen>
});
}
void onLoginTapped() {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return BlocConsumer<AuthBloc, AuthState>(
listener: (BuildContext context, AuthState state) {
if (state.isLoggedIn) {
Navigator.pop(context);
showSnackBar(content: 'Logged in successfully!');
}
},
builder: (BuildContext context, AuthState state) {
return SimpleDialog(
children: <Widget>[
if (state.status == AuthStatus.loading)
const SizedBox(
height: Dimens.pt36,
width: Dimens.pt36,
child: Center(
child: CircularProgressIndicator(
color: Palette.orange,
),
),
)
else if (!state.isLoggedIn) ...<Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt18,
),
child: TextField(
controller: usernameController,
cursorColor: Palette.orange,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'Username',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Palette.orange),
),
),
),
),
const SizedBox(
height: Dimens.pt16,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt18,
),
child: TextField(
controller: passwordController,
cursorColor: Palette.orange,
obscureText: true,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'Password',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Palette.orange),
),
),
),
),
const SizedBox(
height: Dimens.pt16,
),
if (state.status == AuthStatus.failure)
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt18,
),
child: Text(
Constants.errorMessage,
style: const TextStyle(
color: Palette.grey,
fontSize: TextDimens.pt12,
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(
state.agreedToEULA
? Icons.check_box
: Icons.check_box_outline_blank,
color: state.agreedToEULA
? Palette.deepOrange
: Palette.grey,
),
onPressed: () => context
.read<AuthBloc>()
.add(AuthToggleAgreeToEULA()),
),
Text.rich(
TextSpan(
children: <InlineSpan>[
const TextSpan(
text: 'I agree to ',
style: TextStyle(
fontWeight: FontWeight.w500,
),
),
WidgetSpan(
child: Transform.translate(
offset: const Offset(0, 1),
child: TapDownWrapper(
onTap: () => LinkUtil.launch(
Constants.endUserAgreementLink,
),
child: const Text(
'End User Agreement',
style: TextStyle(
color: Palette.deepOrange,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
)
],
),
Padding(
padding: const EdgeInsets.only(
right: Dimens.pt12,
),
child: ButtonBar(
children: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
context.read<AuthBloc>().add(AuthInitialize());
},
child: const Text(
'Cancel',
),
),
ElevatedButton(
onPressed: () {
if (state.agreedToEULA) {
final String username = usernameController.text;
final String password = passwordController.text;
if (username.isNotEmpty && password.isNotEmpty) {
context.read<AuthBloc>().add(
AuthLogin(
username: username,
password: password,
),
);
}
}
},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
state.agreedToEULA
? Palette.deepOrange
: Palette.grey,
),
),
child: const Text(
'Log in',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Palette.white,
),
),
),
],
),
),
],
],
);
},
);
},
);
}
@override
bool get wantKeepAlive => true;
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';

View File

@ -32,13 +32,11 @@ class Settings extends StatefulWidget {
required this.authState,
required this.magicWord,
required this.pageType,
required this.onLoginTapped,
});
final AuthState authState;
final String magicWord;
final PageType pageType;
final VoidCallback onLoginTapped;
@override
State<Settings> createState() => _SettingsState();
@ -69,7 +67,7 @@ class _SettingsState extends State<Settings> {
if (widget.authState.isLoggedIn) {
onLogoutTapped();
} else {
widget.onLoginTapped();
onLoginTapped();
}
},
),
@ -219,6 +217,12 @@ class _SettingsState extends State<Settings> {
},
activeColor: Palette.orange,
),
ListTile(
title: const Text(
'Font',
),
onTap: showFontSettingDialog,
),
ListTile(
title: const Text(
'Theme',
@ -285,6 +289,56 @@ class _SettingsState extends State<Settings> {
);
}
void showFontSettingDialog() {
showDialog<void>(
context: context,
builder: (_) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.font != current.font,
builder: (BuildContext context, PreferenceState state) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (final Font font in Font.values)
RadioListTile<Font>(
value: font,
groupValue: state.font,
onChanged: (Font? val) {
if (val != null) {
context.read<PreferenceCubit>().update(
FontPreference(),
to: val.index,
);
}
},
title: Text(
font.label,
style: TextStyle(fontFamily: font.name),
),
),
Row(
children: const <Widget>[
Text(
'*Restart required',
style: TextStyle(
fontSize: TextDimens.pt12,
color: Palette.grey,
),
),
Spacer(),
],
)
],
),
);
},
);
},
);
}
void showThemeSettingDialog() {
showDialog<void>(
context: context,

View File

@ -12,7 +12,15 @@ import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
const SearchScreen({
super.key,
this.fromUserDialog = false,
});
/// If user gets to [SearchScreen] from user dialog on Tablet,
/// we navigate to [ItemScreen] directly instead of injecting the
/// item into [SplitViewCubit].
final bool fromUserDialog;
@override
_SearchScreenState createState() => _SearchScreenState();
@ -37,6 +45,7 @@ class _SearchScreenState extends State<SearchScreen> {
resizeToAvoidBottomInset: false,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
@ -68,18 +77,13 @@ class _SearchScreenState extends State<SearchScreen> {
child: Row(
children: <Widget>[
const SizedBox(
width: 8,
width: Dimens.pt8,
),
DateTimeRangeFilterChip(
filter: state.params.get<DateTimeRangeFilter>(),
onDateTimeRangeUpdated:
(DateTime start, DateTime end) =>
context.read<SearchCubit>().addFilter(
DateTimeRangeFilter(
startTime: start,
endTime: end,
),
),
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
onDateTimeRangeRemoved: context
.read<SearchCubit>()
.removeFilter<DateTimeRangeFilter>,
@ -87,6 +91,14 @@ class _SearchScreenState extends State<SearchScreen> {
const SizedBox(
width: Dimens.pt8,
),
PostedByFilterChip(
filter: state.params.get<PostedByFilter>(),
onChanged:
context.read<SearchCubit>().onPostedByChanged,
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onSortToggled(),
@ -100,13 +112,9 @@ class _SearchScreenState extends State<SearchScreen> {
in CustomDateTimeRange.values) ...<Widget>[
CustomRangeFilterChip(
range: range,
onTap: (DateTime start, DateTime end) =>
context.read<SearchCubit>().addFilter(
DateTimeRangeFilter(
startTime: start,
endTime: end,
),
),
onTap: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
),
const SizedBox(
width: Dimens.pt8,
@ -115,12 +123,52 @@ class _SearchScreenState extends State<SearchScreen> {
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
for (final TypeTagFilter filter
in TypeTagFilter.all) ...<Widget>[
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onToggled(filter),
selected: context
.read<SearchCubit>()
.state
.params
.get<TypeTagFilter>() ==
filter,
label: filter.query,
),
],
],
),
),
if (state.status == SearchStatus.loading &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
const CustomCircularProgressIndicator(),
const Center(
child: CustomCircularProgressIndicator(),
),
],
if (state.status == SearchStatus.loaded &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
const Center(
child: Text(
'Nothing found...',
style: TextStyle(
color: Palette.grey,
),
),
),
],
Expanded(
child: SmartRefresher(
@ -160,19 +208,33 @@ class _SearchScreenState extends State<SearchScreen> {
children: <Widget>[
...state.results
.map(
(Story e) => <Widget>[
FadeIn(
child: StoryTile(
showWebPreview:
prefState.complexStoryTileEnabled,
showMetadata: prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
story: e,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
(Item e) => <Widget>[
if (e is Story)
FadeIn(
child: StoryTile(
showWebPreview:
prefState.complexStoryTileEnabled,
showMetadata: prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
story: e,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
forceNewScreen: widget.fromUserDialog,
),
),
)
else if (e is Comment)
FadeIn(
child: CommentTile(
actionable: false,
comment: e,
fetchMode: FetchMode.eager,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
forceNewScreen: widget.fromUserDialog,
),
),
),
),
if (!prefState.complexStoryTileEnabled)
const Divider(
height: Dimens.zero,

View File

@ -35,7 +35,7 @@ class DateTimeRangeFilterChip extends StatelessWidget {
},
selected: filter != null,
label:
'''from ${_formatDateTime(filter?.startTime) ?? 'START DATE'} to ${_formatDateTime(filter?.endTime) ?? 'END DATE'}''',
'''from ${_formatDateTime(filter?.startTime) ?? 'X'} to ${_formatDateTime(filter?.endTime) ?? 'Y'}''',
);
}

View File

@ -1,21 +1,107 @@
import 'package:flutter/material.dart';
import 'package:hacki/models/search_params.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class PostedByFilterChip extends StatelessWidget {
const PostedByFilterChip({
super.key,
required this.filter,
required this.onChanged,
});
final PostedByFilter? filter;
final ValueChanged<String?> onChanged;
@override
Widget build(BuildContext context) {
return CustomChip(
onSelected: (bool value) {},
onSelected: (_) async {
final String? username = await onChipTapped(context);
if (username == filter?.author) {
return;
}
onChanged(username);
},
selected: filter != null,
label: '''posted by ${filter?.author ?? ''}''',
label: '''posted by ${filter?.author ?? ''}'''.trimRight(),
);
}
Future<String?> onChipTapped(BuildContext context) async {
final TextEditingController usernameController = TextEditingController();
if (filter?.author != null) {
usernameController.text = filter!.author;
}
final String? username = await showDialog<String?>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return SimpleDialog(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt18,
),
child: TextField(
controller: usernameController,
cursorColor: Palette.orange,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'Username',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Palette.orange),
),
),
),
),
const SizedBox(
height: Dimens.pt16,
),
Padding(
padding: const EdgeInsets.only(
right: Dimens.pt12,
),
child: ButtonBar(
children: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, filter?.author),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text(
'Clear',
),
),
ElevatedButton(
onPressed: () {
final String text = usernameController.text.trim();
Navigator.pop(context, text.isEmpty ? null : text);
},
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(Palette.deepOrange),
),
child: const Text(
'Confirm',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Palette.white,
),
),
),
],
),
),
],
);
},
);
return username;
}
}

View File

@ -1,24 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.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/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/bloc_builder_3.dart';
import 'package:hacki/screens/widgets/centered_text.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class CommentTile extends StatelessWidget {
const CommentTile({
super.key,
required this.myUsername,
required this.comment,
required this.onStoryLinkTapped,
required this.fetchMode,
this.onReplyTapped,
this.onMoreTapped,
@ -27,19 +22,22 @@ class CommentTile extends StatelessWidget {
this.opUsername,
this.actionable = true,
this.level = 0,
this.onTap,
});
final String? myUsername;
final String? opUsername;
final Comment comment;
final int level;
final bool actionable;
final FetchMode fetchMode;
final void Function(Comment)? onReplyTapped;
final void Function(Comment, Rect?)? onMoreTapped;
final void Function(Comment)? onEditTapped;
final void Function(Comment)? onRightMoreTapped;
final void Function(String) onStoryLinkTapped;
final FetchMode fetchMode;
/// Override for search screen.
final VoidCallback? onTap;
static final Map<int, Color> _colors = <int, Color>{};
@ -122,6 +120,8 @@ class CommentTile extends StatelessWidget {
if (actionable) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
} else {
onTap?.call();
}
},
child: Column(
@ -186,61 +186,20 @@ class CommentTile extends StatelessWidget {
top: Dimens.pt6,
bottom: Dimens.pt12,
),
child: comment is BuildableComment
? SelectableText.rich(
key: ValueKey<int>(comment.id),
buildTextSpan(
(comment as BuildableComment)
.elements,
style: TextStyle(
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
decoration:
TextDecoration.underline,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped
.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
onTap: () => onTextTapped(context),
)
: SelectableLinkify(
key: ValueKey<int>(comment.id),
text: comment.text,
style: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
onTap: () => onTextTapped(context),
),
child: SizedBox(
width: double.infinity,
child: ItemText(
key: ValueKey<int>(comment.id),
item: comment,
onTap: () {
if (onTap == null) {
_onTextTapped(context);
} else {
onTap!.call();
}
},
),
),
),
],
),
@ -291,7 +250,8 @@ class CommentTile extends StatelessWidget {
final Color commentColor = prefState.eyeCandyEnabled
? color.withOpacity(commentBackgroundColorOpacity)
: Palette.transparent;
final bool isMyComment = myUsername == comment.by;
final bool isMyComment = comment.deleted == false &&
context.read<AuthBloc>().state.username == comment.by;
Widget wrapper = child;
@ -335,13 +295,6 @@ class CommentTile extends StatelessWidget {
);
}
void onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
}
}
Color _getColor(int level) {
final int initialLevel = level;
if (_colors[initialLevel] != null) return _colors[initialLevel]!;
@ -372,11 +325,19 @@ class CommentTile extends StatelessWidget {
bool _shouldShowLoadButton(BuildContext context) {
final CollapseState collapseState = context.read<CollapseCubit>().state;
final CommentsState commentsState = context.read<CommentsCubit>().state;
final CommentsState? commentsState =
context.tryRead<CommentsCubit>()?.state;
return fetchMode == FetchMode.lazy &&
comment.kids.isNotEmpty &&
collapseState.collapsed == false &&
commentsState.commentIds.contains(comment.kids.first) == false &&
commentsState.onlyShowTargetComment == false;
commentsState?.commentIds.contains(comment.kids.first) == false &&
commentsState?.onlyShowTargetComment == false;
}
void _onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
}
}
}

View File

@ -4,7 +4,7 @@ import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart' show ReminderCubit, ReminderState;
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/story.dart';
import 'package:hacki/models/item/story.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/styles/styles.dart';
@ -108,7 +108,7 @@ class _CountDownReminderState extends State<CountdownReminder>
if (state.storyId != null) {
locator
.get<StoriesRepository>()
.fetchStoryBy(state.storyId!)
.fetchStory(id: state.storyId!)
.then((Story? story) {
if (story == null) {
showErrorSnackBar();

View File

@ -0,0 +1,49 @@
import 'dart:async';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomDescribedFeatureOverlay extends StatelessWidget {
const CustomDescribedFeatureOverlay({
super.key,
required this.featureId,
required this.child,
required this.tapTarget,
required this.title,
required this.description,
this.onComplete,
});
final String featureId;
final Widget tapTarget;
final Widget title;
final Widget description;
final Widget child;
final VoidCallback? onComplete;
@override
Widget build(BuildContext context) {
return DescribedFeatureOverlay(
featureId: featureId,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: tapTarget,
title: title,
description: description,
barrierDismissible: false,
onBackgroundTap: () {
unawaited(HapticFeedback.lightImpact());
FeatureDiscovery.completeCurrentStep(context);
onComplete?.call();
return Future<bool>.value(true);
},
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
onComplete?.call();
return true;
},
child: child,
);
}
}

View File

@ -0,0 +1,403 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
import 'package:hacki/styles/palette.dart';
import 'package:hacki/utils/utils.dart';
import 'package:linkify/linkify.dart';
export 'package:linkify/linkify.dart'
show
LinkifyElement,
LinkifyOptions,
LinkableElement,
TextElement,
Linkifier,
UrlElement,
UrlLinkifier,
EmailElement,
EmailLinkifier;
/// Callback clicked link
typedef LinkCallback = void Function(LinkableElement link);
/// Turns URLs into links
class Linkify extends StatelessWidget {
const Linkify({
super.key,
required this.text,
this.linkifiers = defaultLinkifiers,
this.onOpen,
this.options = const LinkifyOptions(),
// TextSpan
this.style,
this.linkStyle,
// RichText
this.textAlign = TextAlign.start,
this.textDirection,
this.maxLines,
this.overflow = TextOverflow.clip,
this.textScaleFactor = 1.0,
this.softWrap = true,
this.strutStyle,
this.locale,
this.textWidthBasis = TextWidthBasis.parent,
this.textHeightBehavior,
});
/// Text to be linkified
final String text;
/// Linkifiers to be used for linkify
final List<Linkifier> linkifiers;
/// Callback for tapping a link
final LinkCallback? onOpen;
/// linkify's options.
final LinkifyOptions options;
// TextSpan
/// Style for non-link text
final TextStyle? style;
/// Style of link text
final TextStyle? linkStyle;
// Text.rich
/// How the text should be aligned horizontally.
final TextAlign textAlign;
/// Text direction of the text
final TextDirection? textDirection;
/// The maximum number of lines for the text to span, wrapping if necessary
final int? maxLines;
/// How visual overflow should be handled.
final TextOverflow overflow;
/// The number of font pixels for each logical pixel
final double textScaleFactor;
/// Whether the text should break at soft line breaks.
final bool softWrap;
/// The strut style used for the vertical layout
final StrutStyle? strutStyle;
/// Used to select a font when the same Unicode character can
/// be rendered differently, depending on the locale
final Locale? locale;
/// Defines how to measure the width of the rendered text.
final TextWidthBasis textWidthBasis;
/// Defines how the paragraph will apply TextStyle.height to the ascent of
/// the first line and descent of the last line.
final TextHeightBehavior? textHeightBehavior;
@override
Widget build(BuildContext context) {
final List<LinkifyElement> elements = linkify(
text,
options: options,
linkifiers: linkifiers,
);
return Text.rich(
buildTextSpan(
elements,
style: Theme.of(context).textTheme.bodyMedium?.merge(style),
onOpen: onOpen,
useMouseRegion: true,
linkStyle: Theme.of(context)
.textTheme
.bodyMedium
?.merge(style)
.copyWith(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
)
.merge(linkStyle),
),
textAlign: textAlign,
textDirection: textDirection,
maxLines: maxLines,
overflow: overflow,
textScaleFactor: textScaleFactor,
softWrap: softWrap,
strutStyle: strutStyle,
locale: locale,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
);
}
}
const UrlLinkifier _urlLinkifier = UrlLinkifier();
const EmailLinkifier _emailLinkifier = EmailLinkifier();
const QuoteLinkifier _quoteLinkifier = QuoteLinkifier();
const EmphasisLinkifier _emphasisLinkifier = EmphasisLinkifier();
const List<Linkifier> defaultLinkifiers = <Linkifier>[
_urlLinkifier,
_emailLinkifier,
_quoteLinkifier,
_emphasisLinkifier,
];
/// Turns URLs into links
class SelectableLinkify extends StatelessWidget {
const SelectableLinkify({
super.key,
required this.text,
this.linkifiers = defaultLinkifiers,
this.onOpen,
this.options = const LinkifyOptions(),
// TextSpan
this.style,
this.linkStyle,
// RichText
this.textAlign,
this.textDirection,
this.minLines,
this.maxLines,
// SelectableText
this.focusNode,
this.textScaleFactor = 1.0,
this.strutStyle,
this.showCursor = false,
this.autofocus = false,
this.cursorWidth = 2.0,
this.cursorRadius,
this.cursorColor,
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true,
this.onTap,
this.scrollPhysics,
this.textWidthBasis,
this.textHeightBehavior,
this.cursorHeight,
this.selectionControls,
this.onSelectionChanged,
this.contextMenuBuilder = _defaultContextMenuBuilder,
});
/// Text to be linkified
final String text;
/// The number of font pixels for each logical pixel
final double textScaleFactor;
/// Linkifiers to be used for linkify
final List<Linkifier> linkifiers;
/// Callback for tapping a link
final LinkCallback? onOpen;
/// linkify's options.
final LinkifyOptions options;
// TextSpan
/// Style for non-link text
final TextStyle? style;
/// Style of link text
final TextStyle? linkStyle;
// Text.rich
/// How the text should be aligned horizontally.
final TextAlign? textAlign;
/// Text direction of the text
final TextDirection? textDirection;
/// The minimum number of lines to occupy when the content spans fewer lines.
final int? minLines;
/// The maximum number of lines for the text to span, wrapping if necessary
final int? maxLines;
/// The strut style used for the vertical layout
final StrutStyle? strutStyle;
/// Defines how to measure the width of the rendered text.
final TextWidthBasis? textWidthBasis;
// SelectableText.rich
/// Defines the focus for this widget.
final FocusNode? focusNode;
/// Whether to show cursor
final bool showCursor;
/// Whether this text field should focus itself if
/// nothing else is already focused.
final bool autofocus;
/// How thick the cursor will be
final double cursorWidth;
/// How rounded the corners of the cursor should be
final Radius? cursorRadius;
/// The color to use when painting the cursor
final Color? cursorColor;
/// Determines the way that drag start behavior is handled
final DragStartBehavior dragStartBehavior;
/// If true, then long-pressing this TextField will select text and show the cut/copy/paste menu,
/// and tapping will move the text caret
final bool enableInteractiveSelection;
/// Called when the user taps on this selectable text (not link)
final GestureTapCallback? onTap;
final ScrollPhysics? scrollPhysics;
/// Defines how the paragraph will apply TextStyle.height to the ascent of
/// the first line and descent of the last line.
final TextHeightBehavior? textHeightBehavior;
/// How tall the cursor will be.
final double? cursorHeight;
/// Optional delegate for building the text selection handles and toolbar.
final TextSelectionControls? selectionControls;
/// Called when the user changes the selection of text (including the
/// cursor location).
final SelectionChangedCallback? onSelectionChanged;
final EditableTextContextMenuBuilder? contextMenuBuilder;
@override
Widget build(BuildContext context) {
final List<LinkifyElement> elements = LinkifierUtil.linkify(text);
return SelectableText.rich(
buildTextSpan(
elements,
style: Theme.of(context).textTheme.bodyMedium?.merge(style),
onOpen: onOpen,
linkStyle: Theme.of(context)
.textTheme
.bodyMedium
?.merge(style)
.copyWith(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
)
.merge(linkStyle),
),
textAlign: textAlign,
textDirection: textDirection,
minLines: minLines,
maxLines: maxLines,
focusNode: focusNode,
strutStyle: strutStyle,
showCursor: showCursor,
textScaleFactor: textScaleFactor,
autofocus: autofocus,
cursorWidth: cursorWidth,
cursorRadius: cursorRadius,
cursorColor: cursorColor,
dragStartBehavior: dragStartBehavior,
enableInteractiveSelection: enableInteractiveSelection,
onTap: onTap,
scrollPhysics: scrollPhysics,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
cursorHeight: cursorHeight,
selectionControls: selectionControls,
onSelectionChanged: onSelectionChanged,
contextMenuBuilder: contextMenuBuilder,
);
}
static Widget _defaultContextMenuBuilder(
BuildContext context,
EditableTextState editableTextState,
) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}
}
class LinkableSpan extends WidgetSpan {
LinkableSpan({
required MouseCursor mouseCursor,
required InlineSpan inlineSpan,
}) : super(
child: MouseRegion(
cursor: mouseCursor,
child: Text.rich(
inlineSpan,
),
),
);
}
/// Raw TextSpan builder for more control on the RichText
TextSpan buildTextSpan(
List<LinkifyElement> elements, {
TextStyle? style,
TextStyle? linkStyle,
LinkCallback? onOpen,
bool useMouseRegion = false,
}) {
return TextSpan(
children: elements.map<InlineSpan>(
(LinkifyElement element) {
if (element is LinkableElement) {
if (useMouseRegion) {
return LinkableSpan(
mouseCursor: SystemMouseCursors.click,
inlineSpan: TextSpan(
text: element.text,
style: linkStyle,
recognizer: onOpen != null
? (TapGestureRecognizer()..onTap = () => onOpen(element))
: null,
),
);
} else {
return TextSpan(
text: element.text,
style: linkStyle,
recognizer: onOpen != null
? (TapGestureRecognizer()..onTap = () => onOpen(element))
: null,
);
}
} else {
if (element is QuoteElement) {
return TextSpan(
text: element.text,
style: style?.copyWith(
backgroundColor: Palette.orangeAccent.withOpacity(0.3),
),
);
} else if (element is EmphasisElement) {
return TextSpan(
text: element.text,
style: style?.copyWith(
fontStyle: FontStyle.italic,
),
);
}
return TextSpan(
text: element.text,
style: style,
);
}
},
).toList(),
);
}

View File

@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:linkify/linkify.dart';
final RegExp _emphasisRegex = RegExp(
r'\*(.*?)\*',
multiLine: true,
);
class EmphasisLinkifier extends Linkifier {
const EmphasisLinkifier();
@override
List<LinkifyElement> parse(
List<LinkifyElement> elements,
LinkifyOptions options,
) {
final List<LinkifyElement> list = <LinkifyElement>[];
for (final LinkifyElement element in elements) {
if (element is TextElement) {
final RegExpMatch? match = _emphasisRegex.firstMatch(
element.text.trimLeft(),
);
if (element.text == '* * *' ||
match == null ||
match.group(0) == null ||
match.group(1) == null) {
list.add(element);
} else {
final String matchedText = match.group(1)!;
final num pos =
(element.text.indexOf(matchedText) - 1).clamp(0, double.infinity);
final List<String> splitTexts = element.text.split(match.group(0)!);
int curPos = 0;
bool added = false;
for (final String text in splitTexts) {
list.addAll(parse(<LinkifyElement>[TextElement(text)], options));
curPos += text.length;
if (!added && curPos >= pos) {
added = true;
list.add(EmphasisElement(matchedText));
}
}
}
} else {
list.add(element);
}
}
return list;
}
}
/// Represents an element wrapped around '*'.
@immutable
class EmphasisElement extends LinkifyElement {
EmphasisElement(super.text);
@override
String toString() {
return "EmphasisElement: '$text'";
}
@override
bool operator ==(Object other) => equals(other);
@override
bool equals(dynamic other) => other is EmphasisElement && super.equals(other);
@override
int get hashCode => text.hashCode;
}

View File

@ -0,0 +1,2 @@
export 'emphasis_linkifier.dart';
export 'quote_linkifier.dart';

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:linkify/linkify.dart';
final RegExp _quoteRegex = RegExp(
r'(?=^> )(.*?)(?=\n|$)',
multiLine: true,
);
class QuoteLinkifier extends Linkifier {
const QuoteLinkifier();
@override
List<LinkifyElement> parse(
List<LinkifyElement> elements,
LinkifyOptions options,
) {
final List<LinkifyElement> list = <LinkifyElement>[];
for (final LinkifyElement element in elements) {
if (element is TextElement) {
final RegExpMatch? match = _quoteRegex.firstMatch(
element.text.trimLeft(),
);
if (match == null) {
list.add(element);
} else {
final String matchedText = match.group(0)!;
final int pos = element.text.indexOf(matchedText);
final List<String> splitTexts = element.text.split(matchedText);
int curPos = 0;
bool added = false;
for (final String text in splitTexts) {
list.addAll(parse(<TextElement>[TextElement(text)], options));
curPos += text.length;
if (!added && curPos >= pos) {
added = true;
list.add(QuoteElement(matchedText));
}
}
}
} else {
list.add(element);
}
}
return list;
}
}
/// Represents an element that starts with '>'.
@immutable
class QuoteElement extends LinkifyElement {
QuoteElement(super.text);
@override
String toString() {
return "QuoteElement: '$text'";
}
@override
bool operator ==(Object other) => equals(other);
@override
bool equals(dynamic other) => other is QuoteElement && super.equals(other);
@override
int get hashCode => text.hashCode;
}

View File

@ -1,18 +1,12 @@
import 'dart:async';
import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/circle_tab_indicator.dart';
import 'package:hacki/screens/widgets/onboarding_view.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class CustomTabBar extends StatefulWidget {
const CustomTabBar({
@ -27,11 +21,6 @@ class CustomTabBar extends StatefulWidget {
}
class _CustomTabBarState extends State<CustomTabBar> {
final Throttle featureDiscoveryDismissThrottle = Throttle(
delay: _throttleDelay,
);
static const Duration _throttleDelay = Duration(seconds: 1);
late List<StoryType> tabs = context.read<TabCubit>().state.tabs;
int currentIndex = 0;
@ -87,17 +76,8 @@ class _CustomTabBarState extends State<CustomTabBar> {
),
),
Tab(
child: DescribedFeatureOverlay(
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onComplete: () async {
ScaffoldMessenger.of(context).clearSnackBars();
unawaited(HapticFeedback.lightImpact());
showOnboarding();
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
child: CustomDescribedFeatureOverlay(
onComplete: showOnboarding,
tapTarget: const Icon(
Icons.person,
size: TextDimens.pt16,
@ -162,20 +142,4 @@ class _CustomTabBarState extends State<CustomTabBar> {
),
);
}
Future<bool> onFeatureDiscoveryDismissed() {
featureDiscoveryDismissThrottle.run(() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).clearSnackBars();
showSnackBar(content: 'Tap on icon to continue');
});
return Future<bool>.value(false);
}
@override
void dispose() {
featureDiscoveryDismissThrottle.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class ItemText extends StatelessWidget {
const ItemText({
super.key,
required this.item,
this.onTap,
});
final Item item;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final PreferenceState prefState = context.read<PreferenceCubit>().state;
final TextStyle style = TextStyle(
fontSize: prefState.fontSize.fontSize,
);
final TextStyle linkStyle = TextStyle(
fontSize: prefState.fontSize.fontSize,
decoration: TextDecoration.underline,
color: Palette.orange,
);
if (item is Buildable) {
return SelectableText.rich(
buildTextSpan(
(item as Buildable).elements,
style: style,
linkStyle: linkStyle,
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
),
onTap: onTap,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
);
} else {
return SelectableLinkify(
text: item.text,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
style: style,
linkStyle: linkStyle,
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
onTap: onTap,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
);
}
}
}

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';

View File

@ -79,9 +79,8 @@ class StoryTile extends StatelessWidget {
Row(
children: <Widget>[
Expanded(
child: RichText(
textScaleFactor: MediaQuery.of(context).textScaleFactor,
text: TextSpan(
child: Text.rich(
TextSpan(
children: <TextSpan>[
TextSpan(
text: story.title,
@ -105,6 +104,7 @@ class StoryTile extends StatelessWidget {
),
],
),
textScaleFactor: MediaQuery.of(context).textScaleFactor,
),
),
],

View File

@ -5,7 +5,10 @@ export 'comment_tile.dart';
export 'countdown_reminder.dart';
export 'custom_chip.dart';
export 'custom_circular_progress_indicator.dart';
export 'custom_described_feature_overlay.dart';
export 'custom_linkify/custom_linkify.dart';
export 'custom_tab_bar.dart';
export 'item_text.dart';
export 'items_list_view.dart';
export 'link_preview/link_preview.dart';
export 'offline_banner.dart';

View File

@ -6,7 +6,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/utils/html_util.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:path_provider_android/path_provider_android.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:shared_preferences_android/shared_preferences_android.dart';
@ -34,14 +35,21 @@ abstract class Fetcher {
static const int _subscriptionUpperLimit = 15;
static Future<void> fetchReplies() async {
final PreferenceRepository preferenceRepository = PreferenceRepository();
final Logger logger = Logger();
final PreferenceRepository preferenceRepository =
PreferenceRepository(logger: logger);
final AuthRepository authRepository = AuthRepository(
preferenceRepository: preferenceRepository,
logger: logger,
);
final StoriesRepository storiesRepository = StoriesRepository();
final SembastRepository sembastRepository = SembastRepository();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
final String? username = await authRepository.username;
final List<int> unreadIds = await preferenceRepository.unreadCommentsIds;
@ -50,7 +58,7 @@ abstract class Fetcher {
Comment? newReply;
await storiesRepository
.fetchSubmitted(of: username)
.fetchSubmitted(userId: username)
.then((List<int>? submittedItems) async {
if (submittedItems != null) {
final List<int> subscribedItems = submittedItems.sublist(
@ -59,9 +67,7 @@ abstract class Fetcher {
);
for (final int id in subscribedItems) {
await storiesRepository
.fetchRawItemBy(id: id)
.then((Item? item) async {
await storiesRepository.fetchRawItem(id: id).then((Item? item) async {
final List<int> kids = item?.kids ?? <int>[];
final List<int> previousKids =
(await sembastRepository.kids(of: id)) ?? <int>[];
@ -76,7 +82,7 @@ abstract class Fetcher {
if (unreadIds.contains(newCommentId)) continue;
await storiesRepository
.fetchRawCommentBy(id: newCommentId)
.fetchRawComment(id: newCommentId)
.then((Comment? comment) async {
final bool hasPushedBefore =
await preferenceRepository.hasPushed(newReply!.id);

View File

@ -294,7 +294,7 @@ class WebAnalyzer {
// 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);
final Story? story = await storiesRepository.fetchStory(id: storyId);
if (story == null) return null;
@ -304,7 +304,7 @@ class WebAnalyzer {
}
final Comment? comment =
await storiesRepository.fetchCommentBy(id: kids.first);
await storiesRepository.fetchComment(id: kids.first);
return comment != null ? '${comment.by}: ${comment.text}' : null;
}

View File

@ -4,9 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart' show WebViewScreen;
import 'package:hacki/screens/screens.dart'
show ItemScreen, ItemScreenArgs, WebViewScreen;
import 'package:hacki/styles/styles.dart';
import 'package:url_launcher/url_launcher.dart';
@ -45,6 +48,11 @@ abstract class LinkUtil {
return;
}
if (link.isStoryLink) {
_onStoryLinkTapped(link);
return;
}
Uri rinseLink(String link) {
final RegExp regex = RegExp(RegExpConstants.linkSuffix);
if (!link.contains('en.wikipedia.org') && link.contains(regex)) {
@ -80,4 +88,23 @@ abstract class LinkUtil {
}
});
}
static Future<void> _onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
await locator
.get<StoriesRepository>()
.fetchItem(id: id)
.then((Item? item) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
});
} else {
launch(link);
}
}
}

View File

@ -0,0 +1,29 @@
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
import 'package:linkify/linkify.dart';
abstract class LinkifierUtil {
static List<LinkifyElement> linkify(String text) {
const LinkifyOptions options = LinkifyOptions();
const List<Linkifier> linkifiers = <Linkifier>[
UrlLinkifier(),
EmailLinkifier(),
QuoteLinkifier(),
EmphasisLinkifier(),
];
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
if (text.isEmpty) {
return <LinkifyElement>[];
}
if (linkifiers.isEmpty) {
return list;
}
for (final Linkifier linkifier in linkifiers) {
list = linkifier.parse(list, options);
}
return list;
}
}

View File

@ -1,6 +1,7 @@
export 'debouncer.dart';
export 'html_util.dart';
export 'link_util.dart';
export 'linkifier_util.dart';
export 'log_util.dart';
export 'service_exception.dart';
export 'throttle.dart';

View File

@ -37,10 +37,10 @@ packages:
dependency: transitive
description:
name: args
sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611"
sha256: "4cab82a83ffef80b262ddedf47a0a8e56ee6fbf7fe21e6e768b02792034dd440"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
version: "2.4.0"
async:
dependency: transitive
description:
@ -61,18 +61,18 @@ packages:
dependency: "direct main"
description:
name: bloc
sha256: bd4f8027bfa60d96c8046dec5ce74c463b2c918dce1b0d36593575995344534a
sha256: "658a5ae59edcf1e58aac98b000a71c762ad8f46f1394c34a52050cafb3e11a80"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
version: "8.1.1"
bloc_test:
dependency: "direct dev"
description:
name: bloc_test
sha256: "622b97678bf8c06a94f4c26a89ee9ebf7319bf775383dee2233e86e1f94ee28d"
sha256: ffbb60c17ee3d8e3784cb78071088e353199057233665541e8ac6cd438dca8ad
url: "https://pub.dev"
source: hosted
version: "9.1.0"
version: "9.1.1"
boolean_selector:
dependency: transitive
description:
@ -141,18 +141,18 @@ packages:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "745ebcccb1ef73768386154428a55250bc8d44059c19fd27aecda2a6dc013a22"
sha256: "8875e8ed511a49f030e313656154e4bbbcef18d68dfd32eb853fac10bce48e96"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.3"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: b8795b9238bf83b64375f63492034cb3d8e222af4d9ce59dda085edf038fa06f
sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a
url: "https://pub.dev"
source: hosted
version: "1.2.3"
version: "1.2.4"
convert:
dependency: transitive
description:
@ -165,18 +165,18 @@ packages:
dependency: transitive
description:
name: coverage
sha256: "961c4aebd27917269b1896382c7cb1b1ba81629ba669ba09c27a7e5710ec9040"
sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097"
url: "https://pub.dev"
source: hosted
version: "1.6.2"
version: "1.6.3"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f71079978789bc2fe78d79227f1f8cfe195b31bbd8db2399b0d15a4b96fb843b
sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9"
url: "https://pub.dev"
source: hosted
version: "0.3.3+2"
version: "0.3.3+4"
crypto:
dependency: transitive
description:
@ -275,10 +275,10 @@ packages:
dependency: "direct main"
description:
name: flutter_bloc
sha256: "890c51c8007f0182360e523518a0c732efb89876cb4669307af7efada5b55557"
sha256: "434951eea948dbe87f737b674281465f610b8259c16c097b8163ce138749a775"
url: "https://pub.dev"
source: hosted
version: "8.1.1"
version: "8.1.2"
flutter_blurhash:
dependency: transitive
description:
@ -332,14 +332,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.7.2+3"
flutter_linkify:
dependency: "direct main"
description:
name: flutter_linkify
sha256: c89fe74de985ec22f23d3538d2249add085a4f37ac1c29fd79e1a207efb81d63
url: "https://pub.dev"
source: hosted
version: "5.0.2"
flutter_local_notifications:
dependency: "direct main"
description:
@ -368,26 +360,26 @@ packages:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: f2afec1f1762c040a349ea2a588e32f442da5d0db3494a52a929a97c9e550bc5
sha256: "98352186ee7ad3639ccc77ad7924b773ff6883076ab952437d20f18a61f0a7c5"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
version: "8.0.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "736436adaf91552433823f51ce22e098c2f0551db06b6596f58597a25b8ea797"
sha256: "0912ae29a572230ad52d8a4697e5518d7f0f429052fd51df7e5a7952c7efe2a3"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.1.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: ff0768a6700ea1d9620e03518e2e25eac86a8bd07ca3556e9617bfa5ace4bd00
sha256: "083add01847fc1c80a07a08e1ed6927e9acd9618a35e330239d4422cd2a58c50"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
@ -408,10 +400,10 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: ca89c8059cf439985aa83c59619b3674c7ef6cc2e86943d169a7369d6a69cab5
sha256: fc2910ec9b28d60598216c29ea763b3a96c401f0ce1d13cdf69ccb0e5c93c3ee
url: "https://pub.dev"
source: hosted
version: "1.1.3"
version: "2.0.0"
flutter_siri_suggestions:
dependency: "direct main"
description:
@ -442,10 +434,10 @@ packages:
dependency: "direct main"
description:
name: font_awesome_flutter
sha256: "875dbb9ec1ad30d68102019ceb682760d06c72747c1c5b7885781b95f88569cc"
sha256: "959ef4add147753f990b4a7c6cccb746d5792dbdc81b1cde99e62e7edb31b206"
url: "https://pub.dev"
source: hosted
version: "10.3.0"
version: "10.4.0"
frontend_server_client:
dependency: transitive
description:
@ -535,10 +527,10 @@ packages:
dependency: "direct main"
description:
name: hydrated_bloc
sha256: "5871204f14b24638dc9d18d5b94cf22a66fc4be40756925cafff3a7553c7d7b7"
sha256: eb92d88061b6b911c48779b08a91c8a9f3a3aa8475f80d9380045375d9876536
url: "https://pub.dev"
source: hosted
version: "9.0.0"
version: "9.1.0"
integration_test:
dependency: "direct dev"
description: flutter
@ -569,7 +561,7 @@ packages:
source: hosted
version: "0.6.5"
linkify:
dependency: transitive
dependency: "direct main"
description:
name: linkify
sha256: bdfbdafec6cdc9cd0ebb333a868cafc046714ad508e48be8095208c54691d959
@ -676,10 +668,10 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: f619162573096d428ccde2e33f92e05b5a179cd6f0e3120c1005f181bee8ed16
sha256: "8df5ab0a481d7dc20c0e63809e90a588e496d276ba53358afc4c4443d0a00697"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.3"
package_info_plus_platform_interface:
dependency: transitive
description:
@ -724,10 +716,10 @@ packages:
dependency: transitive
description:
name: path_provider_linux
sha256: ab0987bf95bc591da42dffb38c77398fc43309f0b9b894dcc5d6f40c4b26c379
sha256: "2e32f1640f07caef0d3cb993680f181c79e54a3827b997d5ee221490d131fbd9"
url: "https://pub.dev"
source: hosted
version: "2.1.7"
version: "2.1.8"
path_provider_platform_interface:
dependency: transitive
description:
@ -829,10 +821,10 @@ packages:
dependency: "direct main"
description:
name: responsive_builder
sha256: "0f082dff291f5ee4b4ef713d7d1e2a242b126204559024de07039aa7d9012aa5"
sha256: "8eed603781a53fe1804a9ba50089ceb4882887f9c5b84ff139b03d8583a12fc9"
url: "https://pub.dev"
source: hosted
version: "0.5.0+1"
version: "0.5.1"
rxdart:
dependency: "direct main"
description:
@ -853,10 +845,10 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: e387077716f80609bb979cd199331033326033ecd1c8f200a90c5f57b1c9f55e
sha256: "8c6892037b1824e2d7e8f59d54b3105932899008642e6372e5079c6939b4b625"
url: "https://pub.dev"
source: hosted
version: "6.3.0"
version: "6.3.1"
share_plus_platform_interface:
dependency: transitive
description:
@ -885,10 +877,10 @@ packages:
dependency: "direct main"
description:
name: shared_preferences_foundation
sha256: "1ffa239043ab8baf881ec3094a3c767af9d10399b2839020b9e4d44c0bb23951"
sha256: "2b55c18636a4edc529fa5cd44c03d3f3100c00513f518c5127c951978efcccd0"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.3"
shared_preferences_linux:
dependency: transitive
description:
@ -1121,10 +1113,10 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: "698fa0b4392effdc73e9e184403b627362eb5fbf904483ac9defbb1c2191d809"
sha256: e8f2efc804810c0f2f5b485f49e7942179f56eabcfe81dce3387fec4bb55876b
url: "https://pub.dev"
source: hosted
version: "6.1.8"
version: "6.1.9"
url_launcher_android:
dependency: transitive
description:
@ -1137,10 +1129,10 @@ packages:
dependency: transitive
description:
name: url_launcher_ios
sha256: bb328b24d3bccc20bdf1024a0990ac4f869d57663660de9c936fb8c043edefe3
sha256: "0a5af0aefdd8cf820dd739886efb1637f1f24489900204f50984634c07a54815"
url: "https://pub.dev"
source: hosted
version: "6.0.18"
version: "6.1.0"
url_launcher_linux:
dependency: transitive
description:
@ -1201,10 +1193,10 @@ packages:
dependency: "direct dev"
description:
name: very_good_analysis
sha256: "4815adc7ded57657038d2bb2a7f332c50e3c8152f7d3c6acf8f6b7c0cc81e5e2"
sha256: ebc48c51db35beeeec8c414e32f7bd78e612bd7f5992ccb0d46e19edaeb40b08
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "4.0.0+1"
vm_service:
dependency: transitive
description:
@ -1297,10 +1289,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_android
sha256: "9d97fa2bae0f1900553c48a2ef0aaa3864367fd7bb625d683c460754b691312c"
sha256: "5f49a6e5fc59e21fcec5e1bbcd401afbee9792a24a4f3d9cef9b5bb0cd1e3767"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "3.2.4"
webview_flutter_platform_interface:
dependency: transitive
description:
@ -1313,10 +1305,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "523aff9168af9bb2170e4809e0499d7dee065c3919799fd3341d3e616c137960"
sha256: "92e7e7fa468f1df597fb9d37bcf1f303175cbe147c4dbdf06ecc323d950116eb"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.5"
win32:
dependency: transitive
description:
@ -1358,5 +1350,5 @@ packages:
source: hosted
version: "3.1.1"
sdks:
dart: ">=2.18.0 <3.0.0"
flutter: ">=3.7.3"
dart: ">=2.19.0 <3.0.0"
flutter: ">=3.7.5"

View File

@ -1,21 +1,21 @@
name: hacki
description: A Hacker News reader.
version: 1.0.10+88
version: 1.2.1+94
publish_to: none
environment:
sdk: ">=2.17.0 <3.0.0"
flutter: "3.7.3"
flutter: "3.7.5"
dependencies:
adaptive_theme: ^3.0.0
badges: ^3.0.2
bloc: ^8.1.0
cached_network_image: ^3.2.1
bloc: ^8.1.1
cached_network_image: ^3.2.3
clipboard: ^0.1.3
collection: ^1.17.0
connectivity_plus: ^3.0.2
dio: ^4.0.4
dio: ^4.0.6
equatable: ^2.0.5
fast_gbk: ^1.0.0
feature_discovery:
@ -24,53 +24,53 @@ dependencies:
ref: flutter3_compatibility
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
flutter_bloc: ^8.1.2
flutter_cache_manager: ^3.3.0
flutter_email_sender: ^5.2.0
flutter_fadein: ^2.0.0
flutter_feather_icons: 2.0.0+1
flutter_inappwebview: ^5.7.2+3
flutter_linkify: ^5.0.2
flutter_local_notifications: ^13.0.0
flutter_secure_storage: ^7.0.1
flutter_secure_storage: ^8.0.0
flutter_siri_suggestions: ^2.1.0
flutter_slidable: ^2.0.0
font_awesome_flutter: ^10.3.0
gbk_codec: ^0.4.0
get_it: 7.2.0
hive: ^2.0.6
html: ^0.15.0
get_it: ^7.2.0
hive: ^2.2.3
html: ^0.15.1
html_unescape: ^2.0.0
http: ^0.13.3
hydrated_bloc: ^9.0.0-dev.3
http: ^0.13.5
hydrated_bloc: ^9.1.0
intl: ^0.18.0
linkify: ^4.1.0
logger: ^1.1.0
package_info_plus: ^3.0.2
package_info_plus: ^3.0.3
path: ^1.8.2
path_provider: ^2.0.8
path_provider_android: ^2.0.8
path_provider: ^2.0.12
path_provider_android: ^2.0.22
path_provider_foundation: ^2.1.1
pull_to_refresh:
git:
url: https://github.com/livinglist/flutter_pulltorefresh
ref: master
receive_sharing_intent: ^1.4.5
responsive_builder: ^0.5.0+1
rxdart: ^0.27.3
sembast: ^3.1.1+1
share_plus: ^6.3.0
responsive_builder: ^0.5.1
rxdart: ^0.27.7
sembast: ^3.4.0+6
share_plus: ^6.3.1
shared_preferences: ^2.0.17
shared_preferences_android: ^2.0.15
shared_preferences_foundation: ^2.1.2
shared_preferences_foundation: ^2.1.3
shimmer: ^2.0.0
synced_shared_preferences:
path: components/synced_shared_preferences
tuple: ^2.0.0
tuple: ^2.0.1
universal_platform: ^1.0.0+1
url_launcher: ^6.1.3
url_launcher: ^6.1.9
wakelock: ^0.6.1+2
webview_flutter: ^4.0.2
workmanager: ^0.5.0
workmanager: ^0.5.1
dev_dependencies:
bloc_test: ^9.1.0
@ -81,7 +81,7 @@ dev_dependencies:
integration_test:
sdk: flutter
mocktail: ^0.3.0
very_good_analysis: ^3.1.0
very_good_analysis: ^4.0.0+1
flutter:
uses-material-design: true
@ -90,4 +90,21 @@ flutter:
assets:
- assets/images/
fonts:
- family: RobotoSlab
fonts:
- asset: assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
- asset: assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
weight: 700
- family: Ubuntu
fonts:
- asset: assets/fonts/ubuntu/Ubuntu-Regular.ttf
- asset: assets/fonts/ubuntu/Ubuntu-Bold.ttf
weight: 700
- family: UbuntuMono
fonts:
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
weight: 700

View File

@ -67,7 +67,7 @@ void main() {
.thenAnswer((_) => Future<String?>.value(username));
when(() => mockAuthRepository.password)
.thenAnswer((_) => Future<String>.value(password));
when(() => mockStoriesRepository.fetchUserBy(userId: username))
when(() => mockStoriesRepository.fetchUser(id: username))
.thenAnswer((_) => Future<User>.value(tUser));
when(() => mockAuthRepository.loggedIn)
.thenAnswer((_) => Future<bool>.value(false));
@ -91,7 +91,7 @@ void main() {
verify: (_) {
verify(() => mockAuthRepository.loggedIn).called(2);
verifyNever(() => mockAuthRepository.username);
verifyNever(() => mockStoriesRepository.fetchUserBy(userId: username));
verifyNever(() => mockStoriesRepository.fetchUser(id: username));
},
);
@ -154,8 +154,7 @@ void main() {
password: password,
),
).called(1);
verify(() => mockStoriesRepository.fetchUserBy(userId: username))
.called(1);
verify(() => mockStoriesRepository.fetchUser(id: username)).called(1);
},
);
});