Compare commits

..

19 Commits

Author SHA1 Message Date
e77c0e3e73 update bottom sheet. (#190) 2023-03-31 23:15:53 -07:00
cb6f41ec49 add keyword filter. (#189) 2023-03-31 13:59:12 -07:00
ab1e90ccad fix comment tile and bottom navigation bar. (#187) 2023-03-26 19:16:38 -07:00
0ca3e96d91 update story tile. (#183) 2023-03-02 18:36:23 -08:00
d1c8eed3de bump flutter version. (#182) 2023-03-02 00:29:43 -08:00
aa6a2c684c bugfixes. (#181) 2023-03-01 12:24:16 -08:00
d4778d9530 remove bottom padding. (#178) 2023-02-28 15:29:03 -08:00
c702e08481 allow exporting favorites to clipboard. (#177) 2023-02-28 14:54:51 -08:00
2af10391bc update story tile. (#175) 2023-02-27 16:48:53 -08:00
c420dd3ca4 correct spelling. (#174) 2023-02-27 15:16:10 -08:00
da7d0757cd add link to privacy policy. (#173) 2023-02-27 14:48:49 -08:00
32ae2087bc fix link button. (#171) 2023-02-26 23:03:48 -08:00
0b5329d050 bugfixes. (#170) 2023-02-26 15:08:18 -08:00
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
77 changed files with 2040 additions and 1078 deletions

View File

@ -64,12 +64,15 @@ android {
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
}
}
}
flutter {

View File

@ -37,15 +37,6 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>

18
assets/eula.md Normal file
View File

@ -0,0 +1,18 @@
## End-user License Agreement
This policy applies to the usage of the Hacki app.
Please read this Mobile Application End User License Agreement (“EULA”) carefully before using the Hacki mobile application ("Mobile App"), which allows You to read and contribute to Hacker News from Your mobile device. This EULA forms a binding legal agreement between you (and any other entity on whose behalf you accept these terms) (collectively “You” or “Your”) and Hacki (each separately a “Party” and collectively the “Parties”) as of the date you download the Mobile App. Your use of the Mobile App is subject to this EULA.
### Changes to this EULA
Hacki reserves the right to modify this EULA at any time and for any reason. You are responsible for complying with the updated EULA. Your continued use of the Mobile App indicates Your consent to the updated terms.
### No Included Maintenance and Support
Hacki may deploy changes, updates, or enhancements to the Mobile App at any time. Hacki may provide maintenance and support for the Mobile App, but has no obligation whatsoever to furnish such services to You and may terminate such services at any time without notice.
### No Warranty
Hacki expressly disclaims all warranties of any kind, whether express or implied.
The Mobile App is only available for supported devices and might not work on every device. Determining whether Your device is a supported or compatible device for use of the Mobile App is solely Your responsibility, and downloading the Mobile App is done at Your own risk. Smartsheet does not represent or warrant that the Mobile App and Your device are compatible or that the Mobile App will work on Your device.
### Your Consent
By using the app, you consent to the end-user license agreement.

48
assets/privacy_policy.md Normal file
View File

@ -0,0 +1,48 @@
## Privacy Policy
This policy applies to all information collected or submitted on Hacki.
### Information we collect
Hacki collects anonymous statistics such as crash reports and feature usage. These data are solely used to track app's health and are only stored locally on your device and only got sent to us when you choose to do so.
### Ads and analytics
Hacki does not serve ads.
Hacki collects aggregate, anonymous statistics to improve the app but these data are only stored locally on your device and only got sent to us when you choose to do so.
### Information usage
We use the information we collect to operate and improve our website, apps, and customer support.
We do not share personal information with outside parties except to the extent necessary to accomplish Hackis functionality.
We may disclose your information in response to subpoenas, court orders, or other legal requirements; to exercise our legal rights or defend against legal claims; to investigate, prevent, or take action regarding illegal activities, suspected fraud or abuse, violations of our policies; or to protect our rights and property.
### Security
Hacki uses the official Hacker News API for fetching data from Hacker News.
When logging in, usernames and passwords are securely sent to Hacker News' servers for authentication.
### Third-party links and content
Hacki displays links and content from third-party websites. These websites have their own independent privacy policies, and we have no responsibility or liability for their content or activities.
#### California Online Privacy Protection Act Compliance
Hacki complies with the California Online Privacy Protection Act. We therefore will not distribute your personal information to outside parties without your consent.
#### Childrens Online Privacy Protection Act Compliance
Hacki never collects or maintain information at our website from those we actually know are under 13, and no part of our website is structured to attract anyone under 13.
#### Information for European Union Customers
By using Hacki and providing your information, you authorize us to collect, use, and store your information outside of the European Union.
#### International Transfers of Information
Information may be processed, stored, and used outside of the country in which you are located. Data privacy laws vary across jurisdictions, and different laws may be applicable to your data depending on where it is processed, stored, or used.
### Your Consent
By using the app, you consent to the privacy policy.
### Contacting Us
If you have questions regarding this privacy policy, you may e-mail me us at jfeng@fastmail.com.
### Changes to this policy
If we decide to change this privacy policy, we will post those changes on this page.
February 27, 2023: First published.

View File

@ -2,6 +2,8 @@ PODS:
- connectivity_plus (0.0.1):
- Flutter
- ReachabilitySwift
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_email_sender (0.0.1):
- Flutter
@ -53,6 +55,7 @@ PODS:
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
@ -81,6 +84,8 @@ SPEC REPOS:
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
flutter_email_sender:
@ -120,6 +125,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721

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

@ -47,13 +47,14 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
state.copyWith(
isLoggedIn: true,
user: user,
status: AuthStatus.loaded,
),
);
} else {
emit(
state.copyWith(
status: AuthStatus.loaded,
isLoggedIn: false,
status: AuthStatus.loaded,
),
);
}

View File

@ -17,11 +17,13 @@ part 'stories_state.dart';
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesBloc({
required PreferenceCubit preferenceCubit,
required FilterCubit filterCubit,
OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository,
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceCubit = preferenceCubit,
_filterCubit = filterCubit,
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository =
@ -45,6 +47,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}
final PreferenceCubit _preferenceCubit;
final FilterCubit _filterCubit;
final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository;
final PreferenceRepository _preferenceRepository;
@ -74,7 +77,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final int pageSize = getPageSize(isComplexTile: isComplexTile);
emit(
const StoriesState.init().copyWith(
offlineReading: hasCachedStories &&
isOfflineReading: hasCachedStories &&
// Only go into offline mode in the next session.
state.downloadStatus == StoriesDownloadStatus.initial,
currentPageSize: pageSize,
@ -92,7 +95,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required StoryType type,
required Emitter<StoriesState> emit,
}) async {
if (state.offlineReading) {
if (state.isOfflineReading) {
final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type);
emit(
@ -137,7 +140,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
),
);
if (state.offlineReading) {
if (state.isOfflineReading) {
emit(
state.copyWithStatusUpdated(
type: event.type,
@ -172,7 +175,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
upper = len;
}
if (state.offlineReading) {
if (state.isOfflineReading) {
_offlineRepository
.getCachedStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
@ -224,10 +227,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
Emitter<StoriesState> emit,
) async {
final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) =>
event.story.title.toLowerCase().contains(keyword) ||
event.story.text.toLowerCase().contains(keyword),
);
emit(
state.copyWithStoryAdded(
type: event.type,
story: event.story,
story: event.story.copyWith(hidden: hidden),
hasRead: hasRead,
),
);
@ -440,7 +448,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
await _offlineRepository.deleteAllWebPages();
emit(state.copyWith(offlineReading: false));
emit(state.copyWith(isOfflineReading: false));
add(StoriesInitialize());
}

View File

@ -21,7 +21,7 @@ class StoriesState extends Equatable {
required this.statusByType,
required this.currentPageByType,
required this.readStoriesIds,
required this.offlineReading,
required this.isOfflineReading,
required this.downloadStatus,
required this.currentPageSize,
required this.storiesDownloaded,
@ -57,7 +57,7 @@ class StoriesState extends Equatable {
StoryType.ask: 0,
StoryType.show: 0,
},
}) : offlineReading = false,
}) : isOfflineReading = false,
downloadStatus = StoriesDownloadStatus.initial,
currentPageSize = 0,
readStoriesIds = const <int>{},
@ -70,7 +70,7 @@ class StoriesState extends Equatable {
final Map<StoryType, int> currentPageByType;
final Set<int> readStoriesIds;
final StoriesDownloadStatus downloadStatus;
final bool offlineReading;
final bool isOfflineReading;
final int currentPageSize;
final int storiesDownloaded;
final int storiesToBeDownloaded;
@ -82,7 +82,7 @@ class StoriesState extends Equatable {
Map<StoryType, int>? currentPageByType,
Set<int>? readStoriesIds,
StoriesDownloadStatus? downloadStatus,
bool? offlineReading,
bool? isOfflineReading,
int? currentPageSize,
int? storiesDownloaded,
int? storiesToBeDownloaded,
@ -93,7 +93,7 @@ class StoriesState extends Equatable {
statusByType: statusByType ?? this.statusByType,
currentPageByType: currentPageByType ?? this.currentPageByType,
readStoriesIds: readStoriesIds ?? this.readStoriesIds,
offlineReading: offlineReading ?? this.offlineReading,
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
downloadStatus: downloadStatus ?? this.downloadStatus,
currentPageSize: currentPageSize ?? this.currentPageSize,
storiesDownloaded: storiesDownloaded ?? this.storiesDownloaded,
@ -183,7 +183,7 @@ class StoriesState extends Equatable {
statusByType,
currentPageByType,
readStoriesIds,
offlineReading,
isOfflineReading,
downloadStatus,
currentPageSize,
storiesDownloaded,

View File

@ -2,7 +2,9 @@ import 'package:hacki/extensions/extensions.dart';
abstract class Constants {
static const String endUserAgreementLink =
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45';
'https://github.com/Livinglist/Hacki/blob/master/assets/eula.md';
static const String privacyPolicyLink =
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
static const String hackerNewsLogoLink =
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
static const String portfolioLink = 'https://livinglist.github.io';
@ -59,7 +61,15 @@ 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 {

View File

@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
@ -20,17 +21,19 @@ part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({
required FilterCubit filterCubit,
required CollapseCache collapseCache,
CommentCache? commentCache,
OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository,
SembastRepository? sembastRepository,
Logger? logger,
required bool offlineReading,
required bool isOfflineReading,
required Item item,
required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder,
}) : _collapseCache = collapseCache,
}) : _filterCubit = filterCubit,
_collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
@ -41,13 +44,14 @@ class CommentsCubit extends Cubit<CommentsState> {
_logger = logger ?? locator.get<Logger>(),
super(
CommentsState.init(
offlineReading: offlineReading,
isOfflineReading: isOfflineReading,
item: item,
fetchMode: defaultFetchMode,
order: defaultCommentsOrder,
),
);
final FilterCubit _filterCubit;
final CollapseCache _collapseCache;
final CommentCache _commentCache;
final OfflineRepository _offlineRepository;
@ -76,12 +80,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,
),
@ -89,8 +93,8 @@ 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()
@ -109,16 +113,17 @@ class CommentsCubit extends Cubit<CommentsState> {
);
final Item item = state.item;
final Item updatedItem = state.offlineReading
final Item updatedItem = state.isOfflineReading
? item
: await _storiesRepository.fetchItem(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) {
if (state.isOfflineReading) {
commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
} else {
switch (state.fetchMode) {
@ -151,7 +156,7 @@ class CommentsCubit extends Cubit<CommentsState> {
),
);
if (state.offlineReading) {
if (state.isOfflineReading) {
emit(
state.copyWith(
status: CommentsStatus.allLoaded,
@ -273,8 +278,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;
@ -346,9 +352,12 @@ class CommentsCubit extends Cubit<CommentsState> {
_commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment);
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword),
);
final List<Comment> updatedComments = <Comment>[
...state.comments,
comment
comment.copyWith(hidden: hidden),
];
emit(state.copyWith(comments: updatedComments));
@ -380,6 +389,19 @@ class CommentsCubit extends Cubit<CommentsState> {
}
}
static Future<Item?> _toBuildable(Item? item) async {
if (item == null) return null;
switch (item.runtimeType) {
case Comment:
return _toBuildableComment(item as Comment);
case Story:
return _toBuildableStory(item as Story);
}
return null;
}
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
if (comment == null) return null;
@ -395,6 +417,25 @@ class CommentsCubit extends Cubit<CommentsState> {
return buildableComment;
}
static Future<BuildableStory?> _toBuildableStory(Story? story) async {
if (story == null) {
return null;
} else if (story.text.isEmpty) {
return BuildableStory.fromTitleOnlyStory(story);
}
final List<LinkifyElement> elements =
await compute<String, List<LinkifyElement>>(
LinkifierUtil.linkify,
story.text,
);
final BuildableStory buildableStory =
BuildableStory.fromStory(story, elements: elements);
return buildableStory;
}
@override
Future<void> close() async {
await _streamSubscription?.cancel();

View File

@ -17,12 +17,12 @@ class CommentsState extends Equatable {
required this.order,
required this.fetchMode,
required this.onlyShowTargetComment,
required this.offlineReading,
required this.isOfflineReading,
required this.currentPage,
});
CommentsState.init({
required this.offlineReading,
required this.isOfflineReading,
required this.item,
required this.fetchMode,
required this.order,
@ -39,7 +39,7 @@ class CommentsState extends Equatable {
final CommentsOrder order;
final FetchMode fetchMode;
final bool onlyShowTargetComment;
final bool offlineReading;
final bool isOfflineReading;
final int currentPage;
CommentsState copyWith({
@ -50,7 +50,7 @@ class CommentsState extends Equatable {
CommentsOrder? order,
FetchMode? fetchMode,
bool? onlyShowTargetComment,
bool? offlineReading,
bool? isOfflineReading,
int? currentPage,
}) {
return CommentsState(
@ -62,7 +62,7 @@ class CommentsState extends Equatable {
fetchMode: fetchMode ?? this.fetchMode,
onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading,
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage,
);
}
@ -77,7 +77,7 @@ class CommentsState extends Equatable {
order,
fetchMode,
onlyShowTargetComment,
offlineReading,
isOfflineReading,
currentPage,
comments,
];

View File

@ -3,6 +3,7 @@ export 'collapse/collapse_cubit.dart';
export 'comments/comments_cubit.dart';
export 'edit/edit_cubit.dart';
export 'fav/fav_cubit.dart';
export 'filter/filter_cubit.dart';
export 'history/history_cubit.dart';
export 'notification/notification_cubit.dart';
export 'pin/pin_cubit.dart';

View File

@ -160,6 +160,13 @@ class FavCubit extends Cubit<FavState> {
});
}
void removeAll() {
_preferenceRepository
..clearAllFavs(username: '')
..clearAllFavs(username: _authBloc.state.username);
emit(FavState.init());
}
void _onItemLoaded(Item item) {
emit(
state.copyWith(

View File

@ -0,0 +1,40 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/repositories/repositories.dart';
part 'filter_state.dart';
class FilterCubit extends Cubit<FilterState> {
FilterCubit({PreferenceRepository? preferenceRepository})
: _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
super(FilterState.init()) {
init();
}
final PreferenceRepository _preferenceRepository;
void init() {
_preferenceRepository.filterKeywords.then(
(List<String> keywords) => emit(
state.copyWith(
keywords: keywords.toSet(),
),
),
);
}
void addKeyword(String keyword) {
final Set<String> updated = Set<String>.from(state.keywords)..add(keyword);
emit(state.copyWith(keywords: updated));
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
}
void removeKeyword(String keyword) {
final Set<String> updated = Set<String>.from(state.keywords)
..remove(keyword);
emit(state.copyWith(keywords: updated));
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
}
}

View File

@ -0,0 +1,20 @@
part of 'filter_cubit.dart';
class FilterState extends Equatable {
const FilterState({
required this.keywords,
});
FilterState.init() : keywords = <String>{};
final Set<String> keywords;
FilterState copyWith({Set<String>? keywords}) {
return FilterState(
keywords: keywords ?? this.keywords,
);
}
@override
List<Object?> get props => <Object?>[keywords];
}

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

@ -1,5 +1,5 @@
extension DateTimeExtension on DateTime {
String toReadableString() {
String toTimeAgoString() {
final DateTime now = DateTime.now();
final Duration diff = now.difference(this);
if (diff.inDays > 365) {

View File

@ -2,7 +2,18 @@ import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart';
extension ObjectExtension on Object {
void log({String identifier = ''}) {
void log([String identifier = '']) {
locator.get<Logger>().d('$identifier ${toString()}');
}
void logInfo({String identifier = ''}) {
locator.get<Logger>().i('$identifier ${toString()}');
}
void logError({
String identifier = '',
StackTrace? stackTrace,
}) {
locator.get<Logger>().e(identifier, this, stackTrace ?? StackTrace.current);
}
}

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,12 +56,14 @@ 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,
return SafeArea(
child: MorePopupMenu(
item: item,
isBlocked: isBlocked,
onLoginTapped: onLoginTapped,
),
);
},
).then((MenuAction? action) {
@ -74,6 +73,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 +92,13 @@ extension StateExtension on State {
});
}
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 (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);
}
}
@ -117,24 +108,26 @@ extension StateExtension on State {
linkToShare = await showModalBottomSheet<String>(
context: context,
builder: (BuildContext context) {
return Container(
height: 140,
color: Theme.of(context).canvasColor,
child: Material(
child: Column(
children: <Widget>[
ListTile(
onTap: () => Navigator.pop(context, item.url),
title: const Text('Link to article'),
),
ListTile(
onTap: () => Navigator.pop(
context,
'https://news.ycombinator.com/item?id=${item.id}',
return SafeArea(
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: Material(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
onTap: () => Navigator.pop(context, item.url),
title: const Text('Link to article'),
),
title: const Text('Link to HN'),
),
],
ListTile(
onTap: () => Navigator.pop(
context,
'https://news.ycombinator.com/item?id=${item.id}',
),
title: const Text('Link to HN'),
),
],
),
),
),
);
@ -231,17 +224,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,7 +1,7 @@
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/screens/widgets/custom_linkify/custom_linkify.dart';
import 'package:hacki/utils/utils.dart';
extension WidgetModifier on Widget {
@ -15,10 +15,8 @@ extension WidgetModifier on Widget {
Widget contextMenuBuilder(
BuildContext context,
EditableTextState editableTextState, {
required BuildableComment comment,
required Item item,
}) {
final Iterable<EmphasisElement> emphasisElements =
comment.elements.whereType<EmphasisElement>();
final int start = editableTextState.textEditingValue.selection.base.offset;
final int end = editableTextState.textEditingValue.selection.end;
@ -27,22 +25,27 @@ extension WidgetModifier on Widget {
];
if (start != -1 && end != -1) {
String selectedText = comment.text.substring(start, end);
String selectedText = item.text.substring(start, end);
int count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start + count * 2).clamp(0, comment.text.length);
final int e = (end + count * 2).clamp(0, comment.text.length);
selectedText = comment.text.substring(s, e);
count++;
}
if (item is Buildable) {
final Iterable<EmphasisElement> emphasisElements =
(item as Buildable).elements.whereType<EmphasisElement>();
count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start - count * 2).clamp(0, comment.text.length);
final int e = (end - count * 2).clamp(0, comment.text.length);
selectedText = comment.text.substring(s, e);
count++;
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>[

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart';
@ -110,13 +111,27 @@ Future<void> main({bool testing = false}) async {
},
);
} else if (Platform.isAndroid) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Palette.transparent,
systemNavigationBarColor: Palette.transparent,
systemNavigationBarDividerColor: Palette.transparent,
),
);
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
final int sdk = androidInfo.version.sdkInt;
if (sdk > 28) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Palette.transparent,
systemNavigationBarColor: Palette.transparent,
systemNavigationBarDividerColor: Palette.transparent,
),
);
} else {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
statusBarColor: Colors.transparent,
),
);
}
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
@ -168,9 +183,14 @@ class HackiApp extends StatelessWidget {
lazy: false,
create: (BuildContext context) => PreferenceCubit(),
),
BlocProvider<FilterCubit>(
lazy: false,
create: (BuildContext context) => FilterCubit(),
),
BlocProvider<StoriesBloc>(
create: (BuildContext context) => StoriesBloc(
preferenceCubit: context.read<PreferenceCubit>(),
filterCubit: context.read<FilterCubit>(),
),
),
BlocProvider<AuthBloc>(

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,10 +1,10 @@
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';
/// [BuildableComment] is a subtype of [Comment] which stores
/// the corresponding [LinkifyElement] for faster widget building.
class BuildableComment extends Comment {
class BuildableComment extends Comment with Buildable {
BuildableComment({
required super.id,
required super.time,
@ -15,6 +15,7 @@ class BuildableComment extends Comment {
required super.kids,
required super.dead,
required super.deleted,
required super.hidden,
required super.level,
required this.elements,
});
@ -31,7 +32,9 @@ class BuildableComment extends Comment {
dead: comment.dead,
deleted: comment.deleted,
level: comment.level,
hidden: comment.hidden,
);
@override
final List<LinkifyElement> elements;
}

View File

@ -0,0 +1,48 @@
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 super.hidden,
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,
hidden: story.hidden,
);
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({
@ -11,6 +11,7 @@ class Comment extends Item {
required super.kids,
required super.dead,
required super.deleted,
required super.hidden,
required this.level,
}) : super(
descendants: 0,
@ -24,9 +25,12 @@ class Comment extends Item {
final int level;
String get metadata => '''by $by $postedDate''';
String get metadata => '''by $by $timeAgo''';
Comment copyWith({int? level}) {
Comment copyWith({
int? level,
bool? hidden,
}) {
return Comment(
id: id,
time: time,
@ -37,6 +41,7 @@ class Comment extends Item {
kids: kids,
dead: dead,
deleted: deleted,
hidden: hidden ?? this.hidden,
level: level ?? this.level,
);
}

View File

@ -1,8 +1,15 @@
import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.dart';
import 'package:hacki/models/comment.dart';
import 'package:hacki/models/poll_option.dart';
import 'package:hacki/models/story.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 {
@ -21,6 +28,7 @@ class Item extends Equatable {
required this.type,
required this.parts,
required this.descendants,
required this.hidden,
});
Item.empty()
@ -32,9 +40,10 @@ class Item extends Equatable {
title = '',
url = '',
kids = <int>[],
dead = false,
parts = <int>[],
dead = false,
deleted = false,
hidden = false,
parent = 0,
text = '',
type = '';
@ -53,7 +62,8 @@ class Item extends Equatable {
deleted = json['deleted'] as bool? ?? false,
parent = json['parent'] as int? ?? 0,
parts = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
type = json['type'] as String? ?? '';
type = json['type'] as String? ?? '',
hidden = json['hidden'] as bool? ?? false;
final int id;
final int time;
@ -66,6 +76,11 @@ class Item extends Equatable {
final bool deleted;
final bool dead;
/// Whether or not the item should be hidden.
/// true if any of filter keywords set by user presents in [text]
/// or [title].
final bool hidden;
final String by;
final String text;
final String url;
@ -75,8 +90,8 @@ class Item extends Equatable {
final List<int> kids;
final List<int> parts;
String get postedDate =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
String get timeAgo =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString();
bool get isPoll => type == 'poll';
@ -121,5 +136,6 @@ class Item extends Equatable {
type,
parts,
descendants,
hidden,
];
}

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({
@ -20,6 +20,7 @@ class PollOption extends Item {
descendants: 0,
dead: false,
deleted: false,
hidden: false,
);
PollOption.empty()

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({
@ -14,6 +14,7 @@ class Story extends Item {
required super.text,
required super.kids,
required super.parts,
required super.hidden,
}) : super(
dead: false,
deleted: false,
@ -38,15 +39,36 @@ class Story extends Item {
parent: 0,
text: '',
type: '',
hidden: false,
);
Story.fromJson(super.json) : super.fromJson();
Story copyWith({bool? hidden}) {
return Story(
descendants: descendants,
id: id,
score: score,
time: time,
by: by,
title: title,
type: type,
url: url,
text: text,
kids: kids,
parts: parts,
hidden: hidden ?? this.hidden,
);
}
String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $postedDate | $descendants comment${descendants > 1 ? 's' : ''}''';
'''$score point${score > 1 ? 's' : ''} by $by $timeAgo | $descendants comment${descendants > 1 ? 's' : ''}''';
String get screenReaderLabel =>
'''$title, at $readableUrl, by $by $timeAgo. This story has $score point${score > 1 ? 's' : ''} and $descendants comment${descendants > 1 ? 's' : ''}''';
String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate''';
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $timeAgo''';
String get readableUrl {
final Uri url = Uri.parse(this.url);
@ -55,10 +77,5 @@ class Story extends Item {
}
@override
String toString() {
// final String prettyString =
// const JsonEncoder.withIndent(' ').convert(this);
// return 'Story $prettyString';
return 'Story $id';
}
String toString() => 'Story $id';
}

View File

@ -1,14 +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

@ -17,7 +17,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[
// Order of these first four preferences does not matter.
// Order of these preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontPreference(),
@ -31,7 +31,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const NotificationModePreference(),
const SwipeGesturePreference(),
const CollapseModePreference(),
NavigationModePreference(),
const NavigationModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
const EyeCandyModePreference(),
@ -54,8 +54,7 @@ abstract class IntPreference extends Preference<int> {
const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _navigationModeDefaultValueIOS = false;
const bool _navigationModeDefaultValueAndroid = false;
const bool _navigationModeDefaultValue = false;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _readerModeDefaultValue = true;
@ -193,12 +192,9 @@ class StoryUrlModePreference extends BooleanPreference {
/// The value deciding whether or not user should be
/// navigated to web view first. Defaults to false.
class NavigationModePreference extends BooleanPreference {
NavigationModePreference({bool? val})
const NavigationModePreference({bool? val})
: super(
val: val ??
(Platform.isAndroid
? _navigationModeDefaultValueAndroid
: _navigationModeDefaultValueIOS),
val: val ?? _navigationModeDefaultValue,
);
@override

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

@ -22,6 +22,7 @@ class PreferenceRepository {
static const String _usernameKey = 'username';
static const String _passwordKey = 'password';
static const String _blocklistKey = 'blocklist';
static const String _filterKeywordsKey = 'filterKeywords';
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
static const String _lastReadStoryIdKey = 'lastReadStoryId';
@ -207,6 +208,23 @@ class PreferenceRepository {
}
}
Future<void> clearAllFavs({required String username}) async {
final String key = _getFavKey(username);
if (Platform.isIOS) {
await _syncedPrefs.setStringList(
key: key,
val: <String>[],
);
} else {
final SharedPreferences prefs = await _prefs;
await prefs.setStringList(
key,
<String>[],
);
}
}
static String _getFavKey(String username) => 'fav_$username';
//#endregion
@ -257,6 +275,20 @@ class PreferenceRepository {
//#endregion
//#region filter
Future<List<String>> get filterKeywords async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getStringList(_filterKeywordsKey) ?? <String>[],
);
Future<void> updateFilterKeywords(List<String> keywords) async {
final SharedPreferences prefs = await _prefs;
await prefs.setStringList(_filterKeywordsKey, keywords);
}
//#endregion
//#region pins
Future<List<int>> get pinnedStoriesIds async {

View File

@ -13,7 +13,7 @@ class SearchRepository {
final Dio _dio;
Stream<Story> search({
Stream<Item> search({
required SearchParams params,
}) async* {
final String url = '$_baseUrl${params.filteredQuery}';
@ -36,37 +36,55 @@ 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,
hidden: 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>[],
hidden: false,
);
yield story;
}
}
return;
}

View File

@ -214,7 +214,7 @@ class _HomeScreenState extends State<HomeScreen>
context.read<PreferenceCubit>().state.webFirstEnabled;
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
final bool offlineReading =
context.read<StoriesBloc>().state.offlineReading;
context.read<StoriesBloc>().state.isOfflineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
@ -225,7 +225,9 @@ class _HomeScreenState extends State<HomeScreen>
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} else {
final ItemScreenArgs args = ItemScreenArgs(item: story);
final ItemScreenArgs args = ItemScreenArgs(
item: story,
);
context.read<ReminderCubit>().updateLastReadStoryId(story.id);

View File

@ -58,14 +58,15 @@ class ItemScreen extends StatefulWidget {
return MaterialPageRoute<ItemScreen>(
settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => RepositoryProvider<CollapseCache>(
create: (BuildContext context) => CollapseCache(),
create: (_) => CollapseCache(),
lazy: false,
child: MultiBlocProvider(
providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
offlineReading:
context.read<StoriesBloc>().state.offlineReading,
filterCubit: context.read<FilterCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
collapseCache: context.read<CollapseCache>(),
defaultFetchMode:
@ -74,7 +75,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache,
),
),
@ -99,15 +100,16 @@ class ItemScreen extends StatefulWidget {
}
},
child: RepositoryProvider<CollapseCache>(
create: (BuildContext context) => CollapseCache(),
create: (_) => CollapseCache(),
lazy: false,
child: MultiBlocProvider(
key: ValueKey<ItemScreenArgs>(args),
providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
offlineReading:
context.read<StoriesBloc>().state.offlineReading,
filterCubit: context.read<FilterCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
collapseCache: context.read<CollapseCache>(),
defaultFetchMode:
@ -116,7 +118,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
targetAncestors: args.targetComments,
),
),
],
@ -289,8 +291,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
),
@ -365,8 +365,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
bottomSheet: ReplyBox(
@ -438,45 +436,45 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
}
void onRightMoreTapped(Comment comment) {
const double bottomSheetHeight = 140;
HapticFeedback.lightImpact();
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return Container(
height: bottomSheetHeight,
color: Theme.of(context).canvasColor,
child: Material(
color: Palette.transparent,
child: Column(
children: <Widget>[
ListTile(
leading: const Icon(Icons.av_timer),
title: const Text('View parents'),
onTap: () {
Navigator.pop(context);
onTimeMachineActivated(comment);
},
enabled:
comment.level > 0 && !(comment.dead || comment.deleted),
),
ListTile(
leading: const Icon(Icons.list),
title: const Text('View in separate thread'),
onTap: () {
Navigator.pop(context);
goToItemScreen(
args: ItemScreenArgs(
item: comment,
useCommentCache: true,
),
forceNewScreen: true,
);
},
enabled: !(comment.dead || comment.deleted),
),
],
return SafeArea(
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: Material(
color: Palette.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
leading: const Icon(Icons.av_timer),
title: const Text('View ancestors'),
onTap: () {
Navigator.pop(context);
onTimeMachineActivated(comment);
},
enabled:
comment.level > 0 && !(comment.dead || comment.deleted),
),
ListTile(
leading: const Icon(Icons.list),
title: const Text('View in separate thread'),
onTap: () {
Navigator.pop(context);
goToItemScreen(
args: ItemScreenArgs(
item: comment,
useCommentCache: true,
),
forceNewScreen: true,
);
},
enabled: !(comment.dead || comment.deleted),
),
],
),
),
),
);
@ -497,7 +495,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

@ -24,17 +24,17 @@ class LinkIconButton extends StatelessWidget {
featureId: Constants.featureOpenStoryInWebView,
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.',
'''You can tap here to open this story in browser.''',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: Icon(
Icons.stream,
),
),
onPressed: () =>
LinkUtil.launch('https://news.ycombinator.com/item?id=$storyId'),
onPressed: () => LinkUtil.launch(
'https://news.ycombinator.com/item?id=$storyId',
useHackiForHnLink: false,
),
);
}
}

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

@ -25,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,
});
@ -38,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;
@ -91,7 +87,7 @@ class MainView extends StatelessWidget {
onRefresh: () {
HapticFeedback.lightImpact();
if (context.read<StoriesBloc>().state.offlineReading) {
if (context.read<StoriesBloc>().state.isOfflineReading) {
refreshController.refreshCompleted();
} else {
context.read<CommentsCubit>().refresh();
@ -123,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) {
@ -149,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) {
@ -177,7 +169,6 @@ class MainView extends StatelessWidget {
focusNode.requestFocus();
},
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onRightMoreTapped: onRightMoreTapped,
),
);
@ -224,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,
});
@ -238,264 +227,261 @@ 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
Widget build(BuildContext context) {
return Column(
children: <Widget>[
SizedBox(
height: topPadding,
),
if (!splitViewEnabled)
const Padding(
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
return Semantics(
label:
'''Posted by ${state.item.by} ${state.item.timeAgo}, ${state.item.title}. ${state.item.text}''',
child: Column(
children: <Widget>[
SizedBox(
height: topPadding,
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
if (!splitViewEnabled)
const Padding(
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
focusNode.requestFocus();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
focusNode.requestFocus();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
child: Row(
children: <Widget>[
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
),
const Spacer(),
Text(
state.item.postedDate,
style: const TextStyle(
color: Palette.grey,
),
),
],
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
),
child: Row(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
.offlineReading,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
),
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
?.color,
),
children: <TextSpan>[
TextSpan(
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
),
if (state.item.url.isNotEmpty)
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
),
),
],
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
else
const SizedBox(
height: Dimens.pt6,
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
if (state.item.text.isNotEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: SelectableLinkify(
text: state.item.text,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
style: TextStyle(
fontSize: context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
),
linkStyle: TextStyle(
fontSize: context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
),
),
const Spacer(),
Text(
state.item.timeAgo,
style: const TextStyle(
color: Palette.grey,
),
),
],
);
},
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: PollView(
onLoginTapped: onLoginTapped,
),
),
],
),
),
if (state.item.text.isNotEmpty)
const SizedBox(
height: Dimens.pt8,
),
const Divider(
height: Dimens.zero,
),
if (state.onlyShowTargetComment) ...<Widget>[
Center(
child: TextButton(
onPressed: () =>
context.read<CommentsCubit>().loadAll(state.item as Story),
child: const Text('View all comments'),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
.isOfflineReading,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
),
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
?.color,
),
children: <TextSpan>[
TextSpan(
semanticsLabel: state.item.title,
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
),
if (state.item.url.isNotEmpty)
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
),
),
],
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: ItemText(
item: state.item,
),
),
),
],
);
},
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: const PollView(),
),
],
),
),
if (state.item.text.isNotEmpty)
const SizedBox(
height: Dimens.pt8,
),
const Divider(
height: Dimens.zero,
),
] else ...<Widget>[
Row(
children: <Widget>[
if (state.item is Story) ...<Widget>[
const SizedBox(
width: Dimens.pt12,
),
Text(
'''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''',
style: const TextStyle(
fontSize: TextDimens.pt13,
if (state.onlyShowTargetComment) ...<Widget>[
Center(
child: TextButton(
onPressed: () =>
context.read<CommentsCubit>().loadAll(state.item as Story),
child: const Text('View all comments'),
),
),
const Divider(
height: Dimens.zero,
),
] else ...<Widget>[
Row(
children: <Widget>[
if (state.item is Story) ...<Widget>[
const SizedBox(
width: Dimens.pt12,
),
),
] else ...<Widget>[
const SizedBox(
width: Dimens.pt4,
),
TextButton(
onPressed: context.read<CommentsCubit>().loadParentThread,
child: state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
Text(
'''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''',
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
] else ...<Widget>[
const SizedBox(
width: Dimens.pt4,
),
TextButton(
onPressed: context.read<CommentsCubit>().loadParentThread,
child: state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
),
],
const Spacer(),
if (!state.isOfflineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) => DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
),
)
: const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
.toList(),
onChanged: context.read<CommentsCubit>().onFetchModeChanged,
),
const SizedBox(
width: Dimens.pt6,
),
],
const Spacer(),
if (!state.offlineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: FetchMode.values
items: CommentsOrder.values
.map(
(FetchMode val) => DropdownMenuItem<FetchMode>(
(CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
@ -506,51 +492,31 @@ class _ParentItemSection extends StatelessWidget {
),
)
.toList(),
onChanged: context.read<CommentsCubit>().onFetchModeChanged,
onChanged: context.read<CommentsCubit>().onOrderChanged,
),
const SizedBox(
width: Dimens.pt6,
),
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
),
)
.toList(),
onChanged: context.read<CommentsCubit>().onOrderChanged,
),
const SizedBox(
width: Dimens.pt4,
),
],
),
const Divider(
height: Dimens.zero,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.allLoaded) ...<Widget>[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Palette.grey),
const SizedBox(
width: Dimens.pt4,
),
],
),
),
const Divider(
height: Dimens.zero,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.allLoaded) ...<Widget>[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Palette.grey),
),
),
],
],
],
),
);
}
}

View File

@ -6,6 +6,7 @@ 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,13 +16,11 @@ 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;
@override
@ -67,70 +66,83 @@ class MorePopupMenu extends StatelessWidget {
builder: (BuildContext context, VoteState voteState) {
final bool upvoted = voteState.vote == Vote.up;
final bool downvoted = voteState.vote == Vote.down;
return Container(
height: item is Comment ? 430 : 450,
return ColoredBox(
color: Theme.of(context).canvasColor,
child: Material(
color: Palette.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
BlocProvider<UserCubit>(
create: (BuildContext context) =>
UserCubit()..init(userId: item.by),
child: BlocBuilder<UserCubit, UserState>(
builder: (BuildContext context, UserState state) {
return ListTile(
leading: const Icon(
Icons.account_circle,
),
title: Text(item.by),
subtitle: Text(
state.user.description,
),
onTap: () {
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text('About ${state.user.id}'),
content: state.user.about.isEmpty
? Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: const <Widget>[
Text(
'empty',
style: TextStyle(
color: Palette.grey,
),
),
],
)
: SelectableLinkify(
text: HtmlUtil.parseHtml(
state.user.about,
),
linkStyle: const TextStyle(
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Okay',
),
return Semantics(
excludeSemantics: state.status == UserStatus.loading,
child: ListTile(
leading: const Icon(
Icons.account_circle,
),
title: Text(item.by),
subtitle: Text(
state.user.description,
),
onTap: () {
Navigator.pop(context);
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
semanticLabel:
'''About ${state.user.id}. ${state.user.about}''',
title: Text(
'About ${state.user.id}',
),
],
),
);
},
content: state.user.about.isEmpty
? Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: const <Widget>[
Text(
'empty',
style: TextStyle(
color: Palette.grey,
),
),
],
)
: SelectableLinkify(
text: HtmlUtil.parseHtml(
state.user.about,
),
linkStyle: const TextStyle(
color: Palette.orange,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url),
semanticsLabel: state.user.about,
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
onSearchUserTapped(context);
},
child: const Text(
'Search',
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Okay',
),
),
],
),
);
},
),
);
},
),
@ -163,6 +175,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 +243,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

@ -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>(
@ -62,29 +62,29 @@ class PollView extends StatelessWidget {
listener: (BuildContext context, VoteState voteState) {
ScaffoldMessenger.of(context).clearSnackBars();
if (voteState.status == VoteStatus.submitted) {
context.showSnackBar(
showSnackBar(
content: 'Vote submitted successfully.',
);
} else if (voteState.status == VoteStatus.canceled) {
context.showSnackBar(content: 'Vote canceled.');
showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) {
context.showErrorSnackBar();
showErrorSnackBar();
} else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) {
context.showSnackBar(
showSnackBar(
content: "You can't downvote because"
' you are karmaly broke.',
);
} else if (voteState.status ==
VoteStatus.failureNotLoggedIn) {
context.showSnackBar(
showSnackBar(
content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped,
label: 'Log in',
);
} else if (voteState.status ==
VoteStatus.failureBeHumble) {
context.showSnackBar(
showSnackBar(
content: 'No voting on your own post! (;O´)o',
);
}

View File

@ -5,11 +5,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.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,17 +347,8 @@ class _ReplyBoxState extends State<ReplyBox> {
top: Dimens.pt6,
),
child: SingleChildScrollView(
child: SelectableLinkify(
scrollPhysics: const NeverScrollableScrollPhysics(),
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
linkStyle: const TextStyle(
fontSize: TextDimens.pt15,
color: Palette.orange,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url),
text: replyingTo?.text ?? '',
child: ItemText(
item: replyingTo,
),
),
),

View File

@ -34,6 +34,7 @@ class _ScrollUpIconButtonState extends State<ScrollUpIconButton> {
return Opacity(
opacity: opacity.clamp(0, 1),
child: IconButton(
tooltip: 'Scroll to top',
icon: const Icon(
FeatherIcons.chevronsUp,
color: Palette.orange,

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

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
@ -34,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();
@ -56,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>(
@ -238,9 +229,8 @@ class _ProfileScreenState extends State<ProfileScreen>
),
Settings(
authState: authState,
magicWord: magicWord,
magicWord: Constants.magicWord,
pageType: pageType,
onLoginTapped: onLoginTapped,
),
Align(
alignment: Alignment.topLeft,

View File

@ -118,7 +118,7 @@ class InboxView extends StatelessWidget {
Row(
children: <Widget>[
Text(
e.postedDate,
e.timeAgo,
style: const TextStyle(
color: Palette.grey,
),

View File

@ -1,6 +1,7 @@
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -21,7 +22,6 @@ import 'package:hacki/screens/profile/widgets/tab_bar_settings.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
@ -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();
}
},
),
@ -194,7 +192,7 @@ class _SettingsState extends State<Settings> {
.whereType<BooleanPreference>()
.where(
(Preference<dynamic> e) => e.isDisplayable,
))
)) ...<Widget>[
SwitchListTile(
title: Text(preference.title),
subtitle: preference.subtitle.isNotEmpty
@ -219,6 +217,8 @@ class _SettingsState extends State<Settings> {
},
activeColor: Palette.orange,
),
if (preference is StoryUrlModePreference) const Divider(),
],
ListTile(
title: const Text(
'Font',
@ -231,11 +231,30 @@ class _SettingsState extends State<Settings> {
),
onTap: showThemeSettingDialog,
),
const Divider(),
ListTile(
title: const Text(
'Clear Data',
'Filter Keywords',
),
onTap: showClearDataDialog,
onTap: onFilterKeywordsTapped,
),
ListTile(
title: const Text(
'Export Favorites',
),
onTap: onExportFavoritesTapped,
),
ListTile(
title: const Text(
'Clear Favorites',
),
onTap: showClearFavoritesDialog,
),
ListTile(
title: const Text(
'Clear Cache',
),
onTap: showClearCacheDialog,
),
ListTile(
title: const Text('About'),
@ -378,12 +397,12 @@ class _SettingsState extends State<Settings> {
);
}
void showClearDataDialog() {
void showClearCacheDialog() {
showDialog<void>(
context: context,
builder: (_) {
return AlertDialog(
title: const Text('Clear Data?'),
title: const Text('Clear Cache?'),
content: const Text(
'Clear all cached images, stories and comments.',
),
@ -413,7 +432,7 @@ class _SettingsState extends State<Settings> {
DefaultCacheManager().emptyCache,
)
.whenComplete(() {
showSnackBar(content: 'Data cleared!');
showSnackBar(content: 'Cache cleared!');
});
},
child: const Text(
@ -464,6 +483,22 @@ class _SettingsState extends State<Settings> {
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.privacyPolicyLink,
),
child: Row(
children: const <Widget>[
Icon(
Icons.privacy_tip_outlined,
),
SizedBox(
width: Dimens.pt12,
),
Text('Privacy policy'),
],
),
),
ElevatedButton(
onPressed: onReportIssueTapped,
child: Row(
@ -607,11 +642,158 @@ class _SettingsState extends State<Settings> {
LinkUtil.launchInExternalBrowser(Constants.githubIssueLink);
}
} catch (error, stackTrace) {
locator.get<Logger>().e(
'Error caught in onGithubTapped',
error,
stackTrace,
);
error.logError(stackTrace: stackTrace);
}
}
void onFilterKeywordsTapped() {
showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text(
'Filter Keywords',
style: TextStyle(
fontSize: TextDimens.pt16,
),
),
content: BlocBuilder<FilterCubit, FilterState>(
builder: (BuildContext context, FilterState state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (state.keywords.isEmpty)
const CenteredText(
text:
'''story or comment that contains keywords here will be hidden.''',
),
Wrap(
spacing: Dimens.pt4,
children: <Widget>[
for (final String keyword in state.keywords)
ActionChip(
avatar: const Icon(
Icons.close,
size: TextDimens.pt14,
),
label: Text(keyword),
onPressed: () => context
.read<FilterCubit>()
.removeKeyword(keyword),
),
],
),
],
);
},
),
actions: <Widget>[
TextButton(
onPressed: onAddKeywordTapped,
child: const Text(
'Add keyword',
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Okay',
),
),
],
);
},
);
}
void onAddKeywordTapped() {
final TextEditingController controller = TextEditingController();
showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
content: TextField(
autofocus: true,
controller: controller,
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () {
final String keyword = controller.text.trim();
if (keyword.isEmpty) return;
context.read<FilterCubit>().addKeyword(keyword.toLowerCase());
Navigator.pop(context);
},
child: const Text(
'Confirm',
),
),
],
);
},
);
}
Future<void> onExportFavoritesTapped() async {
final List<int> allFavorites = context.read<FavCubit>().state.favIds;
if (allFavorites.isEmpty) {
showSnackBar(content: "You don't have any favorite item.");
return;
}
try {
await FlutterClipboard.copy(
allFavorites.join('\n'),
).whenComplete(HapticFeedback.selectionClick);
showSnackBar(content: 'Ids of favorites have been copied to clipboard.');
} catch (error, stackTrace) {
error.logError(stackTrace: stackTrace);
}
}
void showClearFavoritesDialog() {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Remove all favorites?'),
content: const Text(
'''This will not effect favorites saved in your Hacker News account.''',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
try {
context.read<FavCubit>().removeAll();
showSnackBar(content: 'All favorites have been removed.');
} catch (error, stackTrace) {
error.logError(stackTrace: stackTrace);
}
},
child: const Text(
'Confirm',
style: TextStyle(
color: Palette.red,
),
),
),
],
);
},
);
}
}

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

@ -9,14 +9,11 @@ import 'package:hacki/models/models.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,
@ -25,24 +22,28 @@ 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>{};
@override
Widget build(BuildContext context) {
if (comment.hidden) return const SizedBox.shrink();
return BlocProvider<CollapseCubit>(
key: ValueKey<String>('${comment.id}-BlocProvider'),
lazy: false,
@ -120,6 +121,8 @@ class CommentTile extends StatelessWidget {
if (actionable) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
} else {
onTap?.call();
}
},
child: Column(
@ -150,7 +153,7 @@ class CommentTile extends StatelessWidget {
),
const Spacer(),
Text(
comment.postedDate,
comment.timeAgo,
style: const TextStyle(
color: Palette.grey,
),
@ -186,10 +189,19 @@ class CommentTile extends StatelessWidget {
),
child: SizedBox(
width: double.infinity,
child: _CommentText(
key: ValueKey<int>(comment.id),
comment: comment,
onLinkTapped: _onLinkTapped,
child: Semantics(
label: '''At level ${comment.level}.''',
child: ItemText(
key: ValueKey<int>(comment.id),
item: comment,
onTap: () {
if (onTap == null) {
_onTextTapped(context);
} else {
onTap!.call();
}
},
),
),
),
),
@ -242,7 +254,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;
@ -325,67 +338,7 @@ class CommentTile extends StatelessWidget {
commentsState?.onlyShowTargetComment == false;
}
void _onLinkTapped(LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
}
}
class _CommentText extends StatelessWidget {
const _CommentText({
super.key,
required this.comment,
required this.onLinkTapped,
});
final Comment comment;
final void Function(LinkableElement) onLinkTapped;
@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 (comment is BuildableComment) {
return SelectableText.rich(
buildTextSpan(
(comment as BuildableComment).elements,
style: style,
linkStyle: linkStyle,
onOpen: onLinkTapped,
),
onTap: () => onTextTapped(context),
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
comment: comment as BuildableComment,
),
);
} else {
return SelectableLinkify(
text: comment.text,
style: style,
linkStyle: linkStyle,
onOpen: onLinkTapped,
onTap: () => onTextTapped(context),
);
}
}
void onTextTapped(BuildContext context) {
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/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/styles/styles.dart';

View File

@ -5,6 +5,7 @@ import 'package:hacki/styles/palette.dart';
import 'package:hacki/utils/utils.dart';
import 'package:linkify/linkify.dart';
export 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
export 'package:linkify/linkify.dart'
show
LinkifyElement,
@ -27,7 +28,7 @@ class Linkify extends StatelessWidget {
required this.text,
this.linkifiers = defaultLinkifiers,
this.onOpen,
this.options = const LinkifyOptions(),
this.options = LinkifierUtil.linkifyOptions,
// TextSpan
this.style,
this.linkStyle,
@ -152,9 +153,10 @@ class SelectableLinkify extends StatelessWidget {
const SelectableLinkify({
super.key,
required this.text,
this.semanticsLabel,
this.linkifiers = defaultLinkifiers,
this.onOpen,
this.options = const LinkifyOptions(),
this.options = LinkifierUtil.linkifyOptions,
// TextSpan
this.style,
this.linkStyle,
@ -181,11 +183,14 @@ class SelectableLinkify extends StatelessWidget {
this.cursorHeight,
this.selectionControls,
this.onSelectionChanged,
this.contextMenuBuilder = _defaultContextMenuBuilder,
});
/// Text to be linkified
final String text;
final String? semanticsLabel;
/// The number of font pixels for each logical pixel
final double textScaleFactor;
@ -273,6 +278,8 @@ class SelectableLinkify extends StatelessWidget {
/// cursor location).
final SelectionChangedCallback? onSelectionChanged;
final EditableTextContextMenuBuilder? contextMenuBuilder;
@override
Widget build(BuildContext context) {
final List<LinkifyElement> elements = LinkifierUtil.linkify(text);
@ -312,6 +319,17 @@ class SelectableLinkify extends StatelessWidget {
cursorHeight: cursorHeight,
selectionControls: selectionControls,
onSelectionChanged: onSelectionChanged,
contextMenuBuilder: contextMenuBuilder,
semanticsLabel: semanticsLabel,
);
}
static Widget _defaultContextMenuBuilder(
BuildContext context,
EditableTextState editableTextState,
) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:linkify/linkify.dart';
final RegExp _emphasisRegex = RegExp(

View File

@ -1,8 +1,8 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:linkify/linkify.dart';
final RegExp _quoteRegex = RegExp(
r'(?=^> )(.*?)(?=\n|$)',
r'(?=^>)(.*?)(?=\n|$)',
multiLine: true,
);

View File

@ -0,0 +1,73 @@
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,
),
semanticsLabel: item.text,
);
} 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,
),
semanticsLabel: item.text,
);
}
}
}

View File

@ -200,7 +200,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
Row(
children: <Widget>[
Text(
e.postedDate,
e.timeAgo,
style: const TextStyle(
color: Palette.grey,
),

View File

@ -16,10 +16,9 @@ class LinkPreview extends StatefulWidget {
required this.story,
required this.showMetadata,
required this.showUrl,
required this.offlineReading,
required this.isOfflineReading,
required this.titleStyle,
this.cache = const Duration(days: 30),
this.titleStyle,
this.bodyStyle,
this.showMultimedia = true,
this.backgroundColor = const Color.fromRGBO(235, 235, 235, 1),
this.bodyMaxLines = 3,
@ -84,10 +83,7 @@ class LinkPreview extends StatefulWidget {
final Duration cache;
/// Customize body `TextStyle`
final TextStyle? titleStyle;
/// Customize body `TextStyle`
final TextStyle? bodyStyle;
final TextStyle titleStyle;
/// Show or Hide image if available defaults to `true`
final bool showMultimedia;
@ -105,7 +101,7 @@ class LinkPreview extends StatefulWidget {
final bool showMetadata;
final bool showUrl;
final bool offlineReading;
final bool isOfflineReading;
@override
_LinkPreviewState createState() => _LinkPreviewState();
@ -135,7 +131,7 @@ class _LinkPreviewState extends State<LinkPreview> {
_info = await WebAnalyzer.getInfo(
story: widget.story,
cache: widget.cache,
offlineReading: widget.offlineReading,
offlineReading: widget.isOfflineReading,
);
if (mounted) {
@ -190,7 +186,6 @@ class _LinkPreviewState extends State<LinkPreview> {
imagePath: Constants.hackerNewsLogoPath,
onTap: _launchURL,
titleTextStyle: widget.titleStyle,
bodyTextStyle: widget.bodyStyle,
bodyTextOverflow: widget.bodyTextOverflow,
bodyMaxLines: widget.bodyMaxLines,
showMultiMedia: widget.showMultimedia,

View File

@ -1,10 +1,14 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/models/models.dart';
import 'package:hacki/styles/styles.dart';
class LinkView extends StatelessWidget {
const LinkView({
LinkView({
super.key,
required this.metadata,
required this.url,
@ -13,18 +17,18 @@ class LinkView extends StatelessWidget {
required this.description,
required this.onTap,
required this.showMetadata,
required this.showUrl,
required bool showUrl,
required this.bodyMaxLines,
required this.titleTextStyle,
this.imageUri,
this.imagePath,
this.titleTextStyle,
this.bodyTextStyle,
this.showMultiMedia = true,
this.bodyTextOverflow,
this.bodyMaxLines,
this.isIcon = false,
this.bgColor,
this.radius = 0,
}) : assert(
}) : showUrl = showUrl && url.isNotEmpty,
assert(
!showMultiMedia ||
(showMultiMedia && (imageUri != null || imagePath != null)),
'imageUri or imagePath cannot be null when showMultiMedia is true',
@ -38,35 +42,101 @@ class LinkView extends StatelessWidget {
final String? imageUri;
final String? imagePath;
final void Function(String) onTap;
final TextStyle? titleTextStyle;
final TextStyle? bodyTextStyle;
final TextStyle titleTextStyle;
final bool showMultiMedia;
final TextOverflow? bodyTextOverflow;
final int? bodyMaxLines;
final int bodyMaxLines;
final bool isIcon;
final double radius;
final Color? bgColor;
final bool showMetadata;
final bool showUrl;
double computeTitleFontSize(double width) {
double size = width * 0.13;
if (size > 15) {
size = 15;
}
return size;
}
static const double _bottomPadding = 6;
static late TextStyle _urlStyle;
static late TextStyle _metadataStyle;
static late TextStyle _descriptionStyle;
int computeTitleLines(double layoutHeight) {
return layoutHeight >= 100 ? 2 : 1;
}
static final Map<MaxLineComputationParams, int> _computationCache =
<MaxLineComputationParams, int>{};
int computeBodyLines(double layoutHeight) {
int lines = 1;
if (layoutHeight > 40) {
lines += (layoutHeight - 40.0) ~/ 15.0;
static int getDescriptionMaxLines(
MaxLineComputationParams params,
TextStyle titleStyle,
) {
if (_computationCache.containsKey(params)) {
return _computationCache[params]!;
}
return lines;
_urlStyle = titleStyle.copyWith(
color: Palette.grey,
fontSize: TextDimens.pt12,
fontWeight: FontWeight.w400,
fontFamily: params.fontFamily,
);
_descriptionStyle = TextStyle(
color: Palette.grey,
fontFamily: params.fontFamily,
fontSize: TextDimens.pt14,
);
_metadataStyle = _descriptionStyle.copyWith(
fontSize: TextDimens.pt12,
fontFamily: params.fontFamily,
);
final double urlHeight = (TextPainter(
text: TextSpan(
text: '(url)',
style: _urlStyle,
),
maxLines: 1,
textScaleFactor: params.textScaleFactor,
textDirection: TextDirection.ltr,
)..layout())
.size
.height;
final double metadataHeight = (TextPainter(
text: TextSpan(
text: '123metadata',
style: _metadataStyle,
),
maxLines: 1,
textScaleFactor: params.textScaleFactor,
textDirection: TextDirection.ltr,
)..layout())
.size
.height;
final double descriptionHeight = (TextPainter(
text: TextSpan(
text: 'DESCRIPTION',
style: _descriptionStyle,
),
maxLines: 1,
textScaleFactor: params.textScaleFactor,
textDirection: TextDirection.ltr,
)..layout())
.size
.height;
final double allPaddings =
params.fontFamily == Font.robotoSlab.name ? Dimens.pt2 : Dimens.pt4;
final double height = <double>[
params.titleHeight,
if (params.showUrl) urlHeight,
if (params.showMetadata) metadataHeight,
allPaddings,
_bottomPadding,
].reduce((double a, double b) => a + b);
final double descriptionAllowedHeight = params.layoutHeight - height;
final int maxLines =
max(1, (descriptionAllowedHeight / descriptionHeight).floor());
_computationCache[params] = maxLines;
return maxLines;
}
@override
@ -75,19 +145,36 @@ class LinkView extends StatelessWidget {
builder: (BuildContext context, BoxConstraints constraints) {
final double layoutWidth = constraints.biggest.width;
final double layoutHeight = constraints.biggest.height;
final double bodyWidth = layoutWidth - layoutHeight - 8;
final String? fontFamily =
Theme.of(context).primaryTextTheme.bodyMedium?.fontFamily;
final double textScaleFactor = MediaQuery.of(context).textScaleFactor;
final TextStyle titleFontSize = titleTextStyle ??
TextStyle(
fontSize: computeTitleFontSize(layoutWidth),
color: Palette.black,
fontWeight: FontWeight.bold,
);
final TextStyle bodyFontSize = bodyTextStyle ??
TextStyle(
fontSize: computeTitleFontSize(layoutWidth) - 1,
color: Palette.grey,
fontWeight: FontWeight.w400,
);
final TextStyle titleStyle = titleTextStyle;
final double titleHeight = (TextPainter(
text: TextSpan(
text: title,
style: titleStyle,
),
maxLines: 2,
textScaleFactor: textScaleFactor,
textDirection: TextDirection.ltr,
)..layout(maxWidth: bodyWidth))
.size
.height;
final int descriptionMaxLines = getDescriptionMaxLines(
MaxLineComputationParams(
fontFamily ?? Font.roboto.name,
bodyWidth,
layoutHeight,
titleHeight,
textScaleFactor,
showUrl,
showMetadata,
),
titleStyle,
);
return InkWell(
onTap: () => onTap(url),
@ -96,7 +183,7 @@ class LinkView extends StatelessWidget {
if (showMultiMedia)
Padding(
padding: const EdgeInsets.only(
right: 5,
right: 8,
top: 5,
bottom: 5,
),
@ -112,7 +199,7 @@ class LinkView extends StatelessWidget {
imageUrl: imageUri!,
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
memCacheHeight: layoutHeight.toInt() * 4,
errorWidget: (BuildContext context, _, dynamic __) {
errorWidget: (BuildContext context, _, __) {
return Image.asset(
Constants.hackerNewsLogoPath,
fit: BoxFit.cover,
@ -122,24 +209,53 @@ class LinkView extends StatelessWidget {
),
)
else
const SizedBox(width: 5),
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_buildTitleContainer(
titleFontSize,
computeTitleLines(layoutHeight),
const SizedBox(width: Dimens.pt5),
SizedBox(
height: layoutHeight,
width: layoutWidth - layoutHeight - 8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
height:
Theme.of(context).textTheme.bodyMedium?.fontFamily ==
Font.robotoSlab.name
? Dimens.pt2
: Dimens.pt4,
),
Text(
title,
style: titleStyle,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
if (showUrl)
Text(
'($readableUrl)',
textAlign: TextAlign.left,
style: _urlStyle,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
_buildBodyContainer(
bodyFontSize,
computeBodyLines(layoutHeight),
)
],
),
if (showMetadata)
Text(
metadata,
textAlign: TextAlign.left,
style: _metadataStyle,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
Text(
description,
textAlign: TextAlign.left,
style: _descriptionStyle,
overflow: TextOverflow.ellipsis,
maxLines: descriptionMaxLines,
),
const SizedBox(
height: _bottomPadding,
),
],
),
),
],
@ -148,81 +264,4 @@ class LinkView extends StatelessWidget {
},
);
}
Widget _buildTitleContainer(TextStyle titleTS, int maxLines) {
final bool showUrl = this.showUrl && url.isNotEmpty;
return Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 3, 0),
child: Column(
children: <Widget>[
Container(
alignment: Alignment.topLeft,
child: Text(
title,
style: titleTS,
overflow: TextOverflow.ellipsis,
maxLines: maxLines,
),
),
if (showUrl)
Container(
alignment: Alignment.topLeft,
child: Text(
'($readableUrl)',
textAlign: TextAlign.left,
style: titleTS.copyWith(
color: Palette.grey,
fontSize:
titleTS.fontSize == null ? 12 : titleTS.fontSize! - 4,
fontWeight: FontWeight.w400,
),
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
);
}
Widget _buildBodyContainer(TextStyle bodyTS, int maxLines) {
return Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.fromLTRB(5, 2, 5, 0),
child: Column(
children: <Widget>[
if (showMetadata)
Container(
alignment: Alignment.topLeft,
child: Text(
metadata,
textAlign: TextAlign.left,
style: bodyTS.copyWith(
fontSize:
bodyTS.fontSize == null ? 12 : bodyTS.fontSize! - 2,
),
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
),
Expanded(
child: Container(
alignment: Alignment.topLeft,
child: Text(
description,
textAlign: TextAlign.left,
style: bodyTS,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: (bodyMaxLines ?? maxLines) -
(showMetadata ? 1 : 0) -
(showUrl && url.isNotEmpty ? 1 : 0),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
class MaxLineComputationParams extends Equatable {
const MaxLineComputationParams(
this.fontFamily,
this.layoutWidth,
this.layoutHeight,
this.titleHeight,
this.textScaleFactor,
// ignore: avoid_positional_boolean_parameters
this.showUrl,
this.showMetadata,
);
final String fontFamily;
final double layoutWidth;
final double layoutHeight;
final double titleHeight;
final double textScaleFactor;
final bool showUrl;
final bool showMetadata;
@override
List<Object?> get props => <Object>[
fontFamily,
layoutWidth,
layoutHeight,
titleHeight,
textScaleFactor,
showUrl,
showMetadata,
];
}

View File

@ -0,0 +1 @@
export 'max_line_computation_params.dart';

View File

@ -17,9 +17,9 @@ class OfflineBanner extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<StoriesBloc, StoriesState>(
buildWhen: (StoriesState previous, StoriesState current) =>
previous.offlineReading != current.offlineReading,
previous.isOfflineReading != current.isOfflineReading,
builder: (BuildContext context, StoriesState state) {
if (state.offlineReading) {
if (state.isOfflineReading) {
return MaterialBanner(
content: Text(
'You are currently in offline mode. '

View File

@ -52,15 +52,18 @@ class _OnboardingViewState extends State<OnboardingView> {
children: const <Widget>[
_PageViewChild(
path: Constants.commentTileRightSlidePath,
description: 'Swipe right to leave a comment or vote.',
description:
'''Swipe right to leave a comment, vote, and more.''',
),
_PageViewChild(
path: Constants.commentTileLeftSlidePath,
description: 'Swipe left to view all the parent comments.',
description:
'''Swipe left to view all the ancestor comments.''',
),
_PageViewChild(
path: Constants.commentTileTopTapPath,
description: 'Tap on the top of comment tile to collapse.',
description:
'''Tap on anywhere inside a comment tile to collapse.''',
),
],
),

View File

@ -86,7 +86,7 @@ class _StoriesListViewState extends State<StoriesListView> {
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: state.offlineReading ? null : header,
header: state.isOfflineReading ? null : header,
onMoreTapped: onMoreTapped,
);
},

View File

@ -31,103 +31,113 @@ class StoryTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (story.hidden) return const SizedBox.shrink();
if (showWebPreview) {
final double height = context.storyTileHeight;
return TapDownWrapper(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
),
child: AbsorbPointer(
child: LinkPreview(
story: story,
link: story.url,
offlineReading: context.read<StoriesBloc>().state.offlineReading,
placeholderWidget: _LinkPreviewPlaceholder(
height: height,
return Semantics(
label: story.screenReaderLabel,
excludeSemantics: true,
child: TapDownWrapper(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
),
child: AbsorbPointer(
child: LinkPreview(
story: story,
link: story.url,
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
placeholderWidget: _LinkPreviewPlaceholder(
height: height,
),
errorImage: Constants.hackerNewsLogoLink,
backgroundColor: Palette.transparent,
borderRadius: Dimens.zero,
removeElevation: true,
bodyMaxLines: context.storyTileMaxLines,
errorTitle: story.title,
titleStyle: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context).textTheme.bodyLarge?.color,
fontWeight: FontWeight.bold,
),
showMetadata: showMetadata,
showUrl: showUrl,
),
errorImage: Constants.hackerNewsLogoLink,
backgroundColor: Palette.transparent,
borderRadius: Dimens.zero,
removeElevation: true,
bodyMaxLines: context.storyTileMaxLines,
errorTitle: story.title,
titleStyle: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context).textTheme.bodyLarge?.color,
fontWeight: FontWeight.bold,
),
showMetadata: showMetadata,
showUrl: showUrl,
),
),
),
);
} else {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(left: Dimens.pt12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(
height: Dimens.pt8,
),
Row(
children: <Widget>[
Expanded(
child: Text.rich(
TextSpan(
children: <TextSpan>[
TextSpan(
text: story.title,
style: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context)
.textTheme
.bodyLarge
?.color,
fontSize: simpleTileFontSize,
),
),
if (showUrl && story.url.isNotEmpty)
TextSpan(
text: ' (${story.readableUrl})',
style: TextStyle(
color: Palette.grey[500],
fontSize: simpleTileFontSize - 4,
),
),
],
),
textScaleFactor: MediaQuery.of(context).textScaleFactor,
),
),
],
),
if (showMetadata)
return Semantics(
label: story.screenReaderLabel,
excludeSemantics: true,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(left: Dimens.pt12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(
height: Dimens.pt8,
),
Row(
children: <Widget>[
Expanded(
child: Text(
story.metadata,
style: TextStyle(
color: Palette.grey,
fontSize: simpleTileFontSize - 2,
child: Text.rich(
TextSpan(
children: <TextSpan>[
TextSpan(
text: story.title,
style: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context)
.textTheme
.bodyLarge
?.color,
fontSize: simpleTileFontSize,
),
),
if (showUrl && story.url.isNotEmpty)
TextSpan(
text: ' (${story.readableUrl})',
style: TextStyle(
color: Palette.grey[500],
fontSize: simpleTileFontSize - 4,
),
),
],
),
maxLines: 1,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
),
),
],
),
const SizedBox(
height: Dimens.pt8,
),
],
if (showMetadata)
Row(
children: <Widget>[
Expanded(
child: Text(
story.metadata,
style: TextStyle(
color: Palette.grey,
fontSize: simpleTileFontSize - 2,
),
maxLines: 1,
),
),
],
),
const SizedBox(
height: Dimens.pt8,
),
],
),
),
),
);

View File

@ -8,6 +8,7 @@ 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;

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';
@ -27,6 +30,7 @@ abstract class LinkUtil {
String link, {
bool useReader = false,
bool offlineReading = false,
bool useHackiForHnLink = true,
}) {
if (offlineReading) {
locator
@ -45,6 +49,11 @@ abstract class LinkUtil {
return;
}
if (useHackiForHnLink && 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 +89,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, useHackiForHnLink: false);
}
}
}

View File

@ -2,8 +2,9 @@ import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart'
import 'package:linkify/linkify.dart';
abstract class LinkifierUtil {
static const LinkifyOptions linkifyOptions = LinkifyOptions(humanize: false);
static List<LinkifyElement> linkify(String text) {
const LinkifyOptions options = LinkifyOptions();
const List<Linkifier> linkifiers = <Linkifier>[
UrlLinkifier(),
EmailLinkifier(),
@ -21,7 +22,7 @@ abstract class LinkifierUtil {
}
for (final Linkifier linkifier in linkifiers) {
list = linkifier.parse(list, options);
list = linkifier.parse(list, linkifyOptions);
}
return list;

View File

@ -13,10 +13,10 @@ packages:
dependency: "direct main"
description:
name: adaptive_theme
sha256: "84af26cfc68220df3cd35d9d94cb8953e7182ef560e13d8efb87f32bf1e588fc"
sha256: "61bde10390e937d11d05c6cf0d5cf378a73d49f9a442262e43613dae60ed0b3f"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
version: "3.2.0"
analyzer:
dependency: transitive
description:
@ -201,6 +201,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.8"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "1d6e5a61674ba3a68fb048a7c7b4ff4bebfed8d7379dbe8f2b718231be9a7c95"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64
url: "https://pub.dev"
source: hosted
version: "7.0.0"
diff_match_patch:
dependency: transitive
description:
@ -213,10 +229,10 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8"
sha256: "3e5c4a94d112540d0c9a6b7f3969832e1604eb8cde0f88d0808382f9f632100b"
url: "https://pub.dev"
source: hosted
version: "4.0.6"
version: "5.0.3"
equatable:
dependency: "direct main"
description:
@ -572,10 +588,10 @@ packages:
dependency: "direct main"
description:
name: logger
sha256: "5076f09225f91dc49289a4ccb92df2eeea9ea01cf7c26d49b3a1f04c6a49eec1"
sha256: db2ff852ed77090ba9f62d3611e4208a3d11dfa35991a81ae724c113fcb3e3f7
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.3.0"
logging:
dependency: transitive
description:
@ -600,6 +616,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.0"
memoize:
dependency: "direct main"
description:
name: memoize
sha256: "51481d328c86cbdc59711369179bac88551ca0556569249be5317e66fc796cac"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
meta:
dependency: transitive
description:
@ -1351,4 +1375,4 @@ packages:
version: "3.1.1"
sdks:
dart: ">=2.19.0 <3.0.0"
flutter: ">=3.7.5"
flutter: ">=3.7.9"

View File

@ -1,21 +1,22 @@
name: hacki
description: A Hacker News reader.
version: 1.1.1+92
version: 1.4.0+104
publish_to: none
environment:
sdk: ">=2.17.0 <3.0.0"
flutter: "3.7.5"
flutter: "3.7.9"
dependencies:
adaptive_theme: ^3.0.0
adaptive_theme: ^3.2.0
badges: ^3.0.2
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.6
device_info_plus: ^8.1.0
dio: ^5.0.3
equatable: ^2.0.5
fast_gbk: ^1.0.0
feature_discovery:
@ -44,7 +45,8 @@ dependencies:
hydrated_bloc: ^9.1.0
intl: ^0.18.0
linkify: ^4.1.0
logger: ^1.1.0
logger: ^1.3.0
memoize: ^3.0.0
package_info_plus: ^3.0.3
path: ^1.8.2
path_provider: ^2.0.12