mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
aa6a2c684c | |||
d4778d9530 | |||
c702e08481 | |||
2af10391bc | |||
c420dd3ca4 | |||
da7d0757cd | |||
32ae2087bc | |||
0b5329d050 | |||
c375def289 | |||
3469543c7b | |||
ab755581fd | |||
6b75eb8549 | |||
36ded8a8e3 | |||
582ac7b0be | |||
e5e3391785 | |||
9159fe0fe1 | |||
7c51bad35e | |||
6836138d11 | |||
2f71964277 | |||
c24c5c1b7a |
@ -50,7 +50,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.jiaqifeng.hacki"
|
||||
minSdkVersion 26
|
||||
minSdkVersion 30
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
@ -64,12 +64,15 @@ android {
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
@ -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
18
assets/eula.md
Normal 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.
|
BIN
assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
Normal file
BIN
assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
Normal file
BIN
assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
BIN
assets/fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
BIN
assets/fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
Normal file
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
Normal file
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
Normal file
Binary file not shown.
48
assets/privacy_policy.md
Normal file
48
assets/privacy_policy.md
Normal 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 Hacki’s 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.
|
||||
|
||||
#### Children’s 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.
|
5
fastlane/metadata/android/en-US/changelogs/91.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/91.txt
Normal file
@ -0,0 +1,5 @@
|
||||
- Customization of tab bar.
|
||||
- Option to enable swipe gesture for switching between tabs.
|
||||
- Access to action menu from home screen.
|
||||
- Access to Wikipedia and Wiktionary from text selection toolbar.
|
||||
- Quotes and emphasis rendering.
|
5
fastlane/metadata/android/en-US/changelogs/92.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/92.txt
Normal file
@ -0,0 +1,5 @@
|
||||
- Customization of tab bar.
|
||||
- Option to enable swipe gesture for switching between tabs.
|
||||
- Access to action menu from home screen.
|
||||
- Access to Wikipedia and Wiktionary from text selection toolbar.
|
||||
- Quotes and emphasis rendering.
|
@ -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
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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';
|
||||
@ -16,6 +18,8 @@ abstract class Constants {
|
||||
'https://news.ycombinator.com/newsguidelines.html';
|
||||
static const String githubIssueLink =
|
||||
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
|
||||
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
|
||||
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
|
||||
static const String supportEmail = 'georgefung98@gmail.com';
|
||||
|
||||
static const String _imagePath = 'assets/images';
|
||||
@ -57,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 {
|
||||
|
@ -3,15 +3,18 @@ import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/utils/linkifier_util.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'comments_state.dart';
|
||||
|
||||
@ -73,12 +76,12 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
Future<void> init({
|
||||
bool onlyShowTargetComment = false,
|
||||
bool useCommentCache = false,
|
||||
List<Comment>? targetParents,
|
||||
List<Comment>? targetAncestors,
|
||||
}) async {
|
||||
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) {
|
||||
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
comments: targetParents,
|
||||
comments: targetAncestors,
|
||||
onlyShowTargetComment: true,
|
||||
status: CommentsStatus.allLoaded,
|
||||
),
|
||||
@ -86,9 +89,11 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: targetParents!.last.kids,
|
||||
level: targetParents.last.level + 1,
|
||||
ids: targetAncestors!.last.kids,
|
||||
level: targetAncestors.last.level + 1,
|
||||
)
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
|
||||
@ -106,38 +111,38 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final Item item = state.item;
|
||||
final Item updatedItem = state.offlineReading
|
||||
? item
|
||||
: await _storiesRepository.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) {
|
||||
_streamSubscription = _offlineRepository
|
||||
.getCachedCommentsStream(ids: kids)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
|
||||
} else {
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
break;
|
||||
case FetchMode.eager:
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_streamSubscription = commentStream
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
@ -176,22 +181,23 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
await _storiesRepository.fetchItem(id: item.id) ?? item;
|
||||
final List<int> kids = sortKids(updatedItem.kids);
|
||||
|
||||
late final Stream<Comment> commentStream;
|
||||
if (state.fetchMode == FetchMode.lazy) {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(
|
||||
ids: kids,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
);
|
||||
} else {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
);
|
||||
}
|
||||
|
||||
_streamSubscription = commentStream
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
item: updatedItem,
|
||||
@ -227,23 +233,18 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final StreamSubscription<Comment> streamSubscription =
|
||||
_storiesRepository
|
||||
.fetchCommentsStream(ids: comment.kids)
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen((Comment cmt) {
|
||||
_collapseCache.addKid(cmt.id, to: cmt.parent);
|
||||
_commentCache.cacheComment(cmt);
|
||||
_sembastRepository.cacheComment(cmt);
|
||||
|
||||
final List<LinkifyElement> elements = _linkify(
|
||||
cmt.text,
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(cmt, elements: elements);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
comments: <Comment>[...state.comments]..insert(
|
||||
state.comments.indexOf(comment) + offset + 1,
|
||||
buildableComment.copyWith(level: level),
|
||||
cmt.copyWith(level: level),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -273,8 +274,9 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
Future<void> loadParentThread() async {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
|
||||
final Story? parent =
|
||||
await _storiesRepository.fetchParentStory(id: state.item.id);
|
||||
final Story? parent = await _storiesRepository
|
||||
.fetchParentStory(id: state.item.id)
|
||||
.then(_toBuildableStory);
|
||||
|
||||
if (parent == null) {
|
||||
return;
|
||||
@ -340,22 +342,15 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onCommentFetched(Comment? comment) {
|
||||
void _onCommentFetched(BuildableComment? comment) {
|
||||
if (comment != null) {
|
||||
_collapseCache.addKid(comment.id, to: comment.parent);
|
||||
_commentCache.cacheComment(comment);
|
||||
_sembastRepository.cacheComment(comment);
|
||||
|
||||
final List<LinkifyElement> elements = _linkify(
|
||||
comment.text,
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(comment, elements: elements);
|
||||
|
||||
final List<Comment> updatedComments = <Comment>[
|
||||
...state.comments,
|
||||
buildableComment
|
||||
comment
|
||||
];
|
||||
|
||||
emit(state.copyWith(comments: updatedComments));
|
||||
@ -387,29 +382,51 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
}
|
||||
}
|
||||
|
||||
static List<LinkifyElement> _linkify(
|
||||
String text, {
|
||||
LinkifyOptions options = const LinkifyOptions(),
|
||||
List<Linkifier> linkifiers = const <Linkifier>[
|
||||
UrlLinkifier(),
|
||||
EmailLinkifier(),
|
||||
],
|
||||
}) {
|
||||
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
|
||||
static Future<Item?> _toBuildable(Item? item) async {
|
||||
if (item == null) return null;
|
||||
|
||||
if (text.isEmpty) {
|
||||
return <LinkifyElement>[];
|
||||
switch (item.runtimeType) {
|
||||
case Comment:
|
||||
return _toBuildableComment(item as Comment);
|
||||
case Story:
|
||||
return _toBuildableStory(item as Story);
|
||||
}
|
||||
|
||||
if (linkifiers.isEmpty) {
|
||||
return list;
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
|
||||
if (comment == null) return null;
|
||||
|
||||
final List<LinkifyElement> elements =
|
||||
await compute<String, List<LinkifyElement>>(
|
||||
LinkifierUtil.linkify,
|
||||
comment.text,
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(comment, elements: elements);
|
||||
|
||||
return buildableComment;
|
||||
}
|
||||
|
||||
static Future<BuildableStory?> _toBuildableStory(Story? story) async {
|
||||
if (story == null) {
|
||||
return null;
|
||||
} else if (story.text.isEmpty) {
|
||||
return BuildableStory.fromTitleOnlyStory(story);
|
||||
}
|
||||
|
||||
for (final Linkifier linkifier in linkifiers) {
|
||||
list = linkifier.parse(list, options);
|
||||
}
|
||||
final List<LinkifyElement> elements =
|
||||
await compute<String, List<LinkifyElement>>(
|
||||
LinkifierUtil.linkify,
|
||||
story.text,
|
||||
);
|
||||
|
||||
return list;
|
||||
final BuildableStory buildableStory =
|
||||
BuildableStory.fromStory(story, elements: elements);
|
||||
|
||||
return buildableStory;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -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(
|
||||
|
@ -96,6 +96,9 @@ class PreferenceState extends Equatable {
|
||||
FontSize get fontSize => FontSize.values
|
||||
.elementAt(preferences.singleWhereType<FontSizePreference>().val);
|
||||
|
||||
Font get font =>
|
||||
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
}) {
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
|
@ -5,4 +5,15 @@ extension ObjectExtension on Object {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -2,17 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/item/models/models.dart';
|
||||
import 'package:hacki/screens/item/widgets/widgets.dart';
|
||||
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
extension StateExtension on State {
|
||||
@ -59,11 +56,11 @@ extension StateExtension on State {
|
||||
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
|
||||
showModalBottomSheet<MenuAction>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext context) {
|
||||
return MorePopupMenu(
|
||||
item: item,
|
||||
isBlocked: isBlocked,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onLoginTapped: onLoginTapped,
|
||||
);
|
||||
},
|
||||
@ -74,6 +71,9 @@ extension StateExtension on State {
|
||||
break;
|
||||
case MenuAction.downvote:
|
||||
break;
|
||||
case MenuAction.fav:
|
||||
onFavTapped(item);
|
||||
break;
|
||||
case MenuAction.share:
|
||||
onShareTapped(item, rect);
|
||||
break;
|
||||
@ -90,24 +90,13 @@ extension StateExtension on State {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> onStoryLinkTapped(String link) async {
|
||||
final int? id = link.itemId;
|
||||
if (id != null) {
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -231,17 +220,11 @@ extension StateExtension on State {
|
||||
}
|
||||
|
||||
void onLoginTapped() {
|
||||
final TextEditingController usernameController = TextEditingController();
|
||||
final TextEditingController passwordController = TextEditingController();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return LoginDialog(
|
||||
usernameController: usernameController,
|
||||
passwordController: passwordController,
|
||||
showSnackBar: showSnackBar,
|
||||
);
|
||||
return const LoginDialog();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
extension WidgetModifier on Widget {
|
||||
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
|
||||
@ -7,4 +11,62 @@ extension WidgetModifier on Widget {
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
Widget contextMenuBuilder(
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState, {
|
||||
required Item item,
|
||||
}) {
|
||||
final int start = editableTextState.textEditingValue.selection.base.offset;
|
||||
final int end = editableTextState.textEditingValue.selection.end;
|
||||
|
||||
final List<ContextMenuButtonItem> items = <ContextMenuButtonItem>[
|
||||
...editableTextState.contextMenuButtonItems,
|
||||
];
|
||||
|
||||
if (start != -1 && end != -1) {
|
||||
String selectedText = item.text.substring(start, end);
|
||||
|
||||
if (item is Buildable) {
|
||||
final Iterable<EmphasisElement> emphasisElements =
|
||||
(item as Buildable).elements.whereType<EmphasisElement>();
|
||||
|
||||
int count = 1;
|
||||
while (selectedText.contains(' ') && count <= emphasisElements.length) {
|
||||
final int s = (start + count * 2).clamp(0, item.text.length);
|
||||
final int e = (end + count * 2).clamp(0, item.text.length);
|
||||
selectedText = item.text.substring(s, e);
|
||||
count++;
|
||||
}
|
||||
|
||||
count = 1;
|
||||
while (selectedText.contains(' ') && count <= emphasisElements.length) {
|
||||
final int s = (start - count * 2).clamp(0, item.text.length);
|
||||
final int e = (end - count * 2).clamp(0, item.text.length);
|
||||
selectedText = item.text.substring(s, e);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
items.addAll(<ContextMenuButtonItem>[
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'''${Constants.wikipediaLink}$selectedText''',
|
||||
),
|
||||
label: 'Wikipedia',
|
||||
),
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'''${Constants.wiktionaryLink}$selectedText''',
|
||||
),
|
||||
label: 'Wiktionary',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return AdaptiveTextSelectionToolbar.buttonItems(
|
||||
anchors: editableTextState.contextMenuAnchors,
|
||||
buttonItems: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -128,6 +128,9 @@ Future<void> main({bool testing = false}) async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
final bool trueDarkMode =
|
||||
prefs.getBool(const TrueDarkModePreference().key) ?? false;
|
||||
final Font font = Font.values.elementAt(
|
||||
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
|
||||
);
|
||||
|
||||
Bloc.observer = CustomBlocObserver();
|
||||
|
||||
@ -137,6 +140,7 @@ Future<void> main({bool testing = false}) async {
|
||||
HackiApp(
|
||||
savedThemeMode: savedThemeMode,
|
||||
trueDarkMode: trueDarkMode,
|
||||
font: font,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -146,9 +150,11 @@ class HackiApp extends StatelessWidget {
|
||||
super.key,
|
||||
this.savedThemeMode,
|
||||
required this.trueDarkMode,
|
||||
required this.font,
|
||||
});
|
||||
|
||||
final AdaptiveThemeMode? savedThemeMode;
|
||||
final Font font;
|
||||
final bool trueDarkMode;
|
||||
|
||||
static final GlobalKey<NavigatorState> navigatorKey =
|
||||
@ -227,11 +233,13 @@ class HackiApp extends StatelessWidget {
|
||||
child: AdaptiveTheme(
|
||||
light: ThemeData(
|
||||
primarySwatch: Palette.orange,
|
||||
fontFamily: font.name,
|
||||
),
|
||||
dark: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: trueDarkMode ? Palette.black : null,
|
||||
fontFamily: font.name,
|
||||
),
|
||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||
builder: (ThemeData theme, ThemeData darkTheme) {
|
||||
@ -239,6 +247,7 @@ class HackiApp extends StatelessWidget {
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: Palette.black,
|
||||
fontFamily: font.name,
|
||||
);
|
||||
return FutureBuilder<AdaptiveThemeMode?>(
|
||||
future: AdaptiveTheme.getThemeMode(),
|
||||
|
10
lib/models/font.dart
Normal file
10
lib/models/font.dart
Normal file
@ -0,0 +1,10 @@
|
||||
enum Font {
|
||||
roboto('Roboto'),
|
||||
robotoSlab('Roboto Slab'),
|
||||
ubuntu('Ubuntu'),
|
||||
ubuntuMono('Ubuntu Mono');
|
||||
|
||||
const Font(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
5
lib/models/item/buildable.dart
Normal file
5
lib/models/item/buildable.dart
Normal file
@ -0,0 +1,5 @@
|
||||
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
|
||||
|
||||
mixin Buildable {
|
||||
List<LinkifyElement> get elements;
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/models/comment.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/models/item/buildable.dart';
|
||||
import 'package:hacki/models/item/comment.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
/// [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,
|
||||
@ -33,5 +33,6 @@ class BuildableComment extends Comment {
|
||||
level: comment.level,
|
||||
);
|
||||
|
||||
@override
|
||||
final List<LinkifyElement> elements;
|
||||
}
|
46
lib/models/item/buildable_story.dart
Normal file
46
lib/models/item/buildable_story.dart
Normal file
@ -0,0 +1,46 @@
|
||||
import 'package:hacki/models/item/buildable.dart';
|
||||
import 'package:hacki/models/item/story.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
/// [BuildableStory] is a subtype of [Story] which stores
|
||||
/// the corresponding [LinkifyElement] for faster widget building.
|
||||
class BuildableStory extends Story with Buildable {
|
||||
const BuildableStory({
|
||||
required super.id,
|
||||
required super.time,
|
||||
required super.score,
|
||||
required super.by,
|
||||
required super.text,
|
||||
required super.kids,
|
||||
required super.descendants,
|
||||
required super.title,
|
||||
required super.type,
|
||||
required super.url,
|
||||
required super.parts,
|
||||
required this.elements,
|
||||
});
|
||||
|
||||
BuildableStory.fromStory(Story story, {required this.elements})
|
||||
: super(
|
||||
id: story.id,
|
||||
time: story.time,
|
||||
score: story.score,
|
||||
by: story.by,
|
||||
text: story.text,
|
||||
kids: story.kids,
|
||||
descendants: story.descendants,
|
||||
title: story.title,
|
||||
type: story.type,
|
||||
url: story.url,
|
||||
parts: story.parts,
|
||||
);
|
||||
|
||||
BuildableStory.fromTitleOnlyStory(Story story)
|
||||
: this.fromStory(
|
||||
story,
|
||||
elements: const <LinkifyElement>[],
|
||||
);
|
||||
|
||||
@override
|
||||
final List<LinkifyElement> elements;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import 'package:hacki/models/item.dart';
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
|
||||
class Comment extends Item {
|
||||
Comment({
|
@ -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 {
|
@ -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({
|
@ -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({
|
@ -1,13 +1,10 @@
|
||||
export 'buildable_comment.dart';
|
||||
export 'comment.dart';
|
||||
export 'comments_order.dart';
|
||||
export 'fetch_mode.dart';
|
||||
export 'font.dart';
|
||||
export 'font_size.dart';
|
||||
export 'item.dart';
|
||||
export 'poll_option.dart';
|
||||
export 'item/item.dart';
|
||||
export 'post_data.dart';
|
||||
export 'preference.dart';
|
||||
export 'search_params.dart';
|
||||
export 'story.dart';
|
||||
export 'story_type.dart';
|
||||
export 'user.dart';
|
||||
|
@ -20,6 +20,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
// Order of these first four preferences does not matter.
|
||||
FetchModePreference(),
|
||||
CommentsOrderPreference(),
|
||||
FontPreference(),
|
||||
FontSizePreference(),
|
||||
TabOrderPreference(),
|
||||
// Order of items below matters and
|
||||
@ -30,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(),
|
||||
@ -53,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;
|
||||
@ -65,6 +65,7 @@ const bool _collapseModeDefaultValue = true;
|
||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||
final int _fontSizeDefaultValue = FontSize.regular.index;
|
||||
final int _fontDefaultValue = Font.roboto.index;
|
||||
final int _tabOrderDefaultValue =
|
||||
StoryType.convertToSettingsValue(StoryType.values);
|
||||
|
||||
@ -191,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
|
||||
@ -325,6 +323,21 @@ class CommentsOrderPreference extends IntPreference {
|
||||
String get title => 'Default comments order';
|
||||
}
|
||||
|
||||
class FontPreference extends IntPreference {
|
||||
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
|
||||
|
||||
@override
|
||||
FontPreference copyWith({required int? val}) {
|
||||
return FontPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'font';
|
||||
|
||||
@override
|
||||
String get title => 'Default font';
|
||||
}
|
||||
|
||||
class FontSizePreference extends IntPreference {
|
||||
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -207,6 +207,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
|
||||
|
@ -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,53 @@ class SearchRepository {
|
||||
final int score = hit['points'] as int? ?? 0;
|
||||
final int descendants = hit['num_comments'] as int? ?? 0;
|
||||
|
||||
// Getting rid of comments, only keeping stories for convenience.
|
||||
// Don't judge me.
|
||||
if (title.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String url = hit['url'] as String? ?? '';
|
||||
final String type =
|
||||
title.toLowerCase().contains('poll:') ? 'poll' : 'story';
|
||||
final String text = hit['story_text'] as String? ?? '';
|
||||
final String parsedText = await compute<String, String>(
|
||||
HtmlUtil.parseHtml,
|
||||
text,
|
||||
);
|
||||
final int id = int.parse(hit['objectID'] as String? ?? '0');
|
||||
|
||||
final Story story = Story(
|
||||
descendants: descendants,
|
||||
id: id,
|
||||
score: score,
|
||||
time: createdAt,
|
||||
by: by,
|
||||
title: title,
|
||||
text: parsedText,
|
||||
url: url,
|
||||
type: type,
|
||||
// response doesn't contain kids and parts.
|
||||
kids: const <int>[],
|
||||
parts: const <int>[],
|
||||
);
|
||||
yield story;
|
||||
if (title.isEmpty) {
|
||||
final String text = hit['comment_text'] as String? ?? '';
|
||||
final String parsedText = await compute<String, String>(
|
||||
HtmlUtil.parseHtml,
|
||||
text,
|
||||
);
|
||||
final int parentId = hit['parent_id'] as int? ?? 0;
|
||||
final Comment comment = Comment(
|
||||
id: id,
|
||||
score: score,
|
||||
time: createdAt,
|
||||
by: by,
|
||||
text: parsedText,
|
||||
kids: const <int>[],
|
||||
parent: parentId,
|
||||
dead: false,
|
||||
deleted: false,
|
||||
level: 0,
|
||||
);
|
||||
yield comment;
|
||||
} else {
|
||||
final String text = hit['story_text'] as String? ?? '';
|
||||
final String parsedText = await compute<String, String>(
|
||||
HtmlUtil.parseHtml,
|
||||
text,
|
||||
);
|
||||
final Story story = Story(
|
||||
descendants: descendants,
|
||||
id: id,
|
||||
score: score,
|
||||
time: createdAt,
|
||||
by: by,
|
||||
title: title,
|
||||
text: parsedText,
|
||||
url: url,
|
||||
type: type,
|
||||
// response doesn't contain kids and parts.
|
||||
kids: const <int>[],
|
||||
parts: const <int>[],
|
||||
);
|
||||
yield story;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -74,7 +74,7 @@ class ItemScreen extends StatefulWidget {
|
||||
context.read<PreferenceCubit>().state.order,
|
||||
)..init(
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetParents: args.targetComments,
|
||||
targetAncestors: args.targetComments,
|
||||
useCommentCache: args.useCommentCache,
|
||||
),
|
||||
),
|
||||
@ -116,7 +116,7 @@ class ItemScreen extends StatefulWidget {
|
||||
context.read<PreferenceCubit>().state.order,
|
||||
)..init(
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetParents: args.targetComments,
|
||||
targetAncestors: args.targetComments,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -289,8 +289,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
topPadding: topPadding,
|
||||
splitViewEnabled: widget.splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onLoginTapped: onLoginTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
),
|
||||
),
|
||||
@ -365,8 +363,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
topPadding: topPadding,
|
||||
splitViewEnabled: widget.splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onLoginTapped: onLoginTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
),
|
||||
bottomSheet: ReplyBox(
|
||||
@ -453,7 +449,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.av_timer),
|
||||
title: const Text('View parents'),
|
||||
title: const Text('View ancestors'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onTimeMachineActivated(comment);
|
||||
@ -497,7 +493,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
size: size,
|
||||
deviceType: deviceType,
|
||||
widthFactor: widthFactor,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -1,6 +1,7 @@
|
||||
enum MenuAction {
|
||||
upvote,
|
||||
downvote,
|
||||
fav,
|
||||
share,
|
||||
block,
|
||||
flag,
|
||||
|
@ -33,8 +33,10 @@ class LinkIconButton extends StatelessWidget {
|
||||
Icons.stream,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
LinkUtil.launch('https://news.ycombinator.com/item?id=$storyId'),
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'https://news.ycombinator.com/item?id=$storyId',
|
||||
useHackiForHnLink: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
@ -26,8 +25,6 @@ class MainView extends StatelessWidget {
|
||||
required this.topPadding,
|
||||
required this.splitViewEnabled,
|
||||
required this.onMoreTapped,
|
||||
required this.onStoryLinkTapped,
|
||||
required this.onLoginTapped,
|
||||
required this.onRightMoreTapped,
|
||||
});
|
||||
|
||||
@ -39,8 +36,6 @@ class MainView extends StatelessWidget {
|
||||
final double topPadding;
|
||||
final bool splitViewEnabled;
|
||||
final void Function(Item item, Rect? rect) onMoreTapped;
|
||||
final ValueChanged<String> onStoryLinkTapped;
|
||||
final VoidCallback onLoginTapped;
|
||||
final ValueChanged<Comment> onRightMoreTapped;
|
||||
|
||||
static const int _loadingIndicatorOpacityAnimationDuration = 300;
|
||||
@ -124,8 +119,6 @@ class MainView extends StatelessWidget {
|
||||
topPadding: topPadding,
|
||||
splitViewEnabled: splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onLoginTapped: onLoginTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
);
|
||||
} else if (index == state.comments.length + 1) {
|
||||
@ -150,8 +143,6 @@ class MainView extends StatelessWidget {
|
||||
child: CommentTile(
|
||||
comment: comment,
|
||||
level: comment.level,
|
||||
myUsername:
|
||||
authState.isLoggedIn ? authState.username : null,
|
||||
opUsername: state.item.by,
|
||||
fetchMode: state.fetchMode,
|
||||
onReplyTapped: (Comment cmt) {
|
||||
@ -178,7 +169,6 @@ class MainView extends StatelessWidget {
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
onMoreTapped: onMoreTapped,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
),
|
||||
);
|
||||
@ -225,8 +215,6 @@ class _ParentItemSection extends StatelessWidget {
|
||||
required this.topPadding,
|
||||
required this.splitViewEnabled,
|
||||
required this.onMoreTapped,
|
||||
required this.onStoryLinkTapped,
|
||||
required this.onLoginTapped,
|
||||
required this.onRightMoreTapped,
|
||||
});
|
||||
|
||||
@ -239,8 +227,6 @@ class _ParentItemSection extends StatelessWidget {
|
||||
final double topPadding;
|
||||
final bool splitViewEnabled;
|
||||
final void Function(Item item, Rect? rect) onMoreTapped;
|
||||
final ValueChanged<String> onStoryLinkTapped;
|
||||
final VoidCallback onLoginTapped;
|
||||
final ValueChanged<Comment> onRightMoreTapped;
|
||||
|
||||
@override
|
||||
@ -340,12 +326,8 @@ class _ParentItemSection extends StatelessWidget {
|
||||
bottom: Dimens.pt12,
|
||||
top: Dimens.pt12,
|
||||
),
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
textScaleFactor: MediaQuery.of(
|
||||
context,
|
||||
).textScaleFactor,
|
||||
text: TextSpan(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: prefState.fontSize.fontSize,
|
||||
@ -378,6 +360,10 @@ class _ParentItemSection extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
textScaleFactor: MediaQuery.of(
|
||||
context,
|
||||
).textScaleFactor,
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -392,32 +378,8 @@ class _ParentItemSection extends StatelessWidget {
|
||||
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);
|
||||
}
|
||||
},
|
||||
child: ItemText(
|
||||
item: state.item,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -429,9 +391,7 @@ class _ParentItemSection extends StatelessWidget {
|
||||
BlocProvider<PollCubit>(
|
||||
create: (BuildContext context) =>
|
||||
PollCubit(story: state.item as Story)..init(),
|
||||
child: PollView(
|
||||
onLoginTapped: onLoginTapped,
|
||||
),
|
||||
child: const PollView(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -1,12 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/item/models/models.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
@ -15,15 +16,16 @@ class MorePopupMenu extends StatelessWidget {
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.isBlocked,
|
||||
required this.onStoryLinkTapped,
|
||||
required this.onLoginTapped,
|
||||
});
|
||||
|
||||
final Item item;
|
||||
final bool isBlocked;
|
||||
final ValueChanged<String> onStoryLinkTapped;
|
||||
final VoidCallback onLoginTapped;
|
||||
|
||||
static const double _storySheetHeight = 500;
|
||||
static const double _commentSheetHeight = 480;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<VoteCubit>(
|
||||
@ -68,7 +70,7 @@ class MorePopupMenu extends StatelessWidget {
|
||||
final bool upvoted = voteState.vote == Vote.up;
|
||||
final bool downvoted = voteState.vote == Vote.down;
|
||||
return Container(
|
||||
height: item is Comment ? 430 : 450,
|
||||
height: item is Comment ? _commentSheetHeight : _storySheetHeight,
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
color: Palette.transparent,
|
||||
@ -88,6 +90,7 @@ class MorePopupMenu extends StatelessWidget {
|
||||
state.user.description,
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
@ -112,15 +115,19 @@ class MorePopupMenu extends StatelessWidget {
|
||||
linkStyle: const TextStyle(
|
||||
color: Palette.orange,
|
||||
),
|
||||
onOpen: (LinkableElement link) {
|
||||
if (link.url.isStoryLink) {
|
||||
onStoryLinkTapped.call(link.url);
|
||||
} else {
|
||||
LinkUtil.launch(link.url);
|
||||
}
|
||||
},
|
||||
onOpen: (LinkableElement link) =>
|
||||
LinkUtil.launch(link.url),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
onSearchUserTapped(context);
|
||||
},
|
||||
child: const Text(
|
||||
'Search',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
@ -163,6 +170,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 +238,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,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
@ -3,13 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/item.dart';
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/link_util.dart';
|
||||
|
||||
class ReplyBox extends StatefulWidget {
|
||||
const ReplyBox({
|
||||
@ -256,6 +255,8 @@ class _ReplyBoxState extends State<ReplyBox> {
|
||||
void showTextPopup() {
|
||||
final Item? replyingTo = context.read<EditCubit>().state.replyingTo;
|
||||
|
||||
if (replyingTo == null) return;
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
@ -280,37 +281,49 @@ class _ReplyBoxState extends State<ReplyBox> {
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
replyingTo?.by ?? '',
|
||||
style: const TextStyle(color: Palette.grey),
|
||||
replyingTo.by,
|
||||
style: const TextStyle(
|
||||
fontSize: TextDimens.pt14,
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (replyingTo != null)
|
||||
TextButton(
|
||||
child: const Text('View thread'),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
expanded = false;
|
||||
});
|
||||
Navigator.popUntil(
|
||||
context,
|
||||
(Route<dynamic> route) =>
|
||||
route.settings.name == ItemScreen.routeName ||
|
||||
route.isFirst,
|
||||
);
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: replyingTo,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: const Text('Copy all'),
|
||||
child: const Text(
|
||||
'View thread',
|
||||
style: TextStyle(
|
||||
fontSize: TextDimens.pt14,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
expanded = false;
|
||||
});
|
||||
Navigator.popUntil(
|
||||
context,
|
||||
(Route<dynamic> route) =>
|
||||
route.settings.name == ItemScreen.routeName ||
|
||||
route.isFirst,
|
||||
);
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: replyingTo,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: const Text(
|
||||
'Copy all',
|
||||
style: TextStyle(
|
||||
fontSize: TextDimens.pt14,
|
||||
),
|
||||
),
|
||||
onPressed: () => FlutterClipboard.copy(
|
||||
replyingTo?.text ?? '',
|
||||
replyingTo.text,
|
||||
).then((_) => HapticFeedback.selectionClick()),
|
||||
),
|
||||
IconButton(
|
||||
@ -334,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
@ -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,17 +217,38 @@ class _SettingsState extends State<Settings> {
|
||||
},
|
||||
activeColor: Palette.orange,
|
||||
),
|
||||
if (preference is StoryUrlModePreference) const Divider(),
|
||||
],
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Font',
|
||||
),
|
||||
onTap: showFontSettingDialog,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Theme',
|
||||
),
|
||||
onTap: showThemeSettingDialog,
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Clear Data',
|
||||
'Export Favorites',
|
||||
),
|
||||
onTap: showClearDataDialog,
|
||||
onTap: onExportFavoritesTapped,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Clear Favorites',
|
||||
),
|
||||
onTap: showClearFavoritesDialog,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Clear Cache',
|
||||
),
|
||||
onTap: showClearCacheDialog,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('About'),
|
||||
@ -285,6 +304,56 @@ class _SettingsState extends State<Settings> {
|
||||
);
|
||||
}
|
||||
|
||||
void showFontSettingDialog() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.font != current.font,
|
||||
builder: (BuildContext context, PreferenceState state) {
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
for (final Font font in Font.values)
|
||||
RadioListTile<Font>(
|
||||
value: font,
|
||||
groupValue: state.font,
|
||||
onChanged: (Font? val) {
|
||||
if (val != null) {
|
||||
context.read<PreferenceCubit>().update(
|
||||
FontPreference(),
|
||||
to: val.index,
|
||||
);
|
||||
}
|
||||
},
|
||||
title: Text(
|
||||
font.label,
|
||||
style: TextStyle(fontFamily: font.name),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: const <Widget>[
|
||||
Text(
|
||||
'*Restart required',
|
||||
style: TextStyle(
|
||||
fontSize: TextDimens.pt12,
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void showThemeSettingDialog() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
@ -322,12 +391,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.',
|
||||
),
|
||||
@ -357,7 +426,7 @@ class _SettingsState extends State<Settings> {
|
||||
DefaultCacheManager().emptyCache,
|
||||
)
|
||||
.whenComplete(() {
|
||||
showSnackBar(content: 'Data cleared!');
|
||||
showSnackBar(content: 'Cache cleared!');
|
||||
});
|
||||
},
|
||||
child: const Text(
|
||||
@ -408,6 +477,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(
|
||||
@ -551,11 +636,64 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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'}''',
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/bloc_builder_3.dart';
|
||||
import 'package:hacki/screens/widgets/centered_text.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class CommentTile extends StatelessWidget {
|
||||
const CommentTile({
|
||||
super.key,
|
||||
required this.myUsername,
|
||||
required this.comment,
|
||||
required this.onStoryLinkTapped,
|
||||
required this.fetchMode,
|
||||
this.onReplyTapped,
|
||||
this.onMoreTapped,
|
||||
@ -27,19 +22,22 @@ class CommentTile extends StatelessWidget {
|
||||
this.opUsername,
|
||||
this.actionable = true,
|
||||
this.level = 0,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String? myUsername;
|
||||
final String? opUsername;
|
||||
final Comment comment;
|
||||
final int level;
|
||||
final bool actionable;
|
||||
final FetchMode fetchMode;
|
||||
|
||||
final void Function(Comment)? onReplyTapped;
|
||||
final void Function(Comment, Rect?)? onMoreTapped;
|
||||
final void Function(Comment)? onEditTapped;
|
||||
final void Function(Comment)? onRightMoreTapped;
|
||||
final void Function(String) onStoryLinkTapped;
|
||||
final FetchMode fetchMode;
|
||||
|
||||
/// Override for search screen.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
static final Map<int, Color> _colors = <int, Color>{};
|
||||
|
||||
@ -122,6 +120,8 @@ class CommentTile extends StatelessWidget {
|
||||
if (actionable) {
|
||||
HapticFeedback.selectionClick();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
} else {
|
||||
onTap?.call();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
@ -188,10 +188,16 @@ class CommentTile extends StatelessWidget {
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _CommentText(
|
||||
child: ItemText(
|
||||
key: ValueKey<int>(comment.id),
|
||||
comment: comment,
|
||||
onLinkTapped: _onLinkTapped,
|
||||
item: comment,
|
||||
onTap: () {
|
||||
if (onTap == null) {
|
||||
_onTextTapped(context);
|
||||
} else {
|
||||
onTap!.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -244,7 +250,8 @@ class CommentTile extends StatelessWidget {
|
||||
final Color commentColor = prefState.eyeCandyEnabled
|
||||
? color.withOpacity(commentBackgroundColorOpacity)
|
||||
: Palette.transparent;
|
||||
final bool isMyComment = myUsername == comment.by;
|
||||
final bool isMyComment = comment.deleted == false &&
|
||||
context.read<AuthBloc>().state.username == comment.by;
|
||||
|
||||
Widget wrapper = child;
|
||||
|
||||
@ -327,58 +334,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),
|
||||
);
|
||||
} 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();
|
||||
|
@ -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';
|
||||
|
404
lib/screens/widgets/custom_linkify/custom_linkify.dart
Normal file
404
lib/screens/widgets/custom_linkify/custom_linkify.dart
Normal file
@ -0,0 +1,404 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
|
||||
import 'package:hacki/styles/palette.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
export 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
|
||||
export 'package:linkify/linkify.dart'
|
||||
show
|
||||
LinkifyElement,
|
||||
LinkifyOptions,
|
||||
LinkableElement,
|
||||
TextElement,
|
||||
Linkifier,
|
||||
UrlElement,
|
||||
UrlLinkifier,
|
||||
EmailElement,
|
||||
EmailLinkifier;
|
||||
|
||||
/// Callback clicked link
|
||||
typedef LinkCallback = void Function(LinkableElement link);
|
||||
|
||||
/// Turns URLs into links
|
||||
class Linkify extends StatelessWidget {
|
||||
const Linkify({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.linkifiers = defaultLinkifiers,
|
||||
this.onOpen,
|
||||
this.options = LinkifierUtil.linkifyOptions,
|
||||
// TextSpan
|
||||
this.style,
|
||||
this.linkStyle,
|
||||
// RichText
|
||||
this.textAlign = TextAlign.start,
|
||||
this.textDirection,
|
||||
this.maxLines,
|
||||
this.overflow = TextOverflow.clip,
|
||||
this.textScaleFactor = 1.0,
|
||||
this.softWrap = true,
|
||||
this.strutStyle,
|
||||
this.locale,
|
||||
this.textWidthBasis = TextWidthBasis.parent,
|
||||
this.textHeightBehavior,
|
||||
});
|
||||
|
||||
/// Text to be linkified
|
||||
final String text;
|
||||
|
||||
/// Linkifiers to be used for linkify
|
||||
final List<Linkifier> linkifiers;
|
||||
|
||||
/// Callback for tapping a link
|
||||
final LinkCallback? onOpen;
|
||||
|
||||
/// linkify's options.
|
||||
final LinkifyOptions options;
|
||||
|
||||
// TextSpan
|
||||
|
||||
/// Style for non-link text
|
||||
final TextStyle? style;
|
||||
|
||||
/// Style of link text
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
// Text.rich
|
||||
|
||||
/// How the text should be aligned horizontally.
|
||||
final TextAlign textAlign;
|
||||
|
||||
/// Text direction of the text
|
||||
final TextDirection? textDirection;
|
||||
|
||||
/// The maximum number of lines for the text to span, wrapping if necessary
|
||||
final int? maxLines;
|
||||
|
||||
/// How visual overflow should be handled.
|
||||
final TextOverflow overflow;
|
||||
|
||||
/// The number of font pixels for each logical pixel
|
||||
final double textScaleFactor;
|
||||
|
||||
/// Whether the text should break at soft line breaks.
|
||||
final bool softWrap;
|
||||
|
||||
/// The strut style used for the vertical layout
|
||||
final StrutStyle? strutStyle;
|
||||
|
||||
/// Used to select a font when the same Unicode character can
|
||||
/// be rendered differently, depending on the locale
|
||||
final Locale? locale;
|
||||
|
||||
/// Defines how to measure the width of the rendered text.
|
||||
final TextWidthBasis textWidthBasis;
|
||||
|
||||
/// Defines how the paragraph will apply TextStyle.height to the ascent of
|
||||
/// the first line and descent of the last line.
|
||||
final TextHeightBehavior? textHeightBehavior;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<LinkifyElement> elements = linkify(
|
||||
text,
|
||||
options: options,
|
||||
linkifiers: linkifiers,
|
||||
);
|
||||
|
||||
return Text.rich(
|
||||
buildTextSpan(
|
||||
elements,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.merge(style),
|
||||
onOpen: onOpen,
|
||||
useMouseRegion: true,
|
||||
linkStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.merge(style)
|
||||
.copyWith(
|
||||
color: Colors.blueAccent,
|
||||
decoration: TextDecoration.underline,
|
||||
)
|
||||
.merge(linkStyle),
|
||||
),
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
textScaleFactor: textScaleFactor,
|
||||
softWrap: softWrap,
|
||||
strutStyle: strutStyle,
|
||||
locale: locale,
|
||||
textWidthBasis: textWidthBasis,
|
||||
textHeightBehavior: textHeightBehavior,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const UrlLinkifier _urlLinkifier = UrlLinkifier();
|
||||
const EmailLinkifier _emailLinkifier = EmailLinkifier();
|
||||
const QuoteLinkifier _quoteLinkifier = QuoteLinkifier();
|
||||
const EmphasisLinkifier _emphasisLinkifier = EmphasisLinkifier();
|
||||
const List<Linkifier> defaultLinkifiers = <Linkifier>[
|
||||
_urlLinkifier,
|
||||
_emailLinkifier,
|
||||
_quoteLinkifier,
|
||||
_emphasisLinkifier,
|
||||
];
|
||||
|
||||
/// Turns URLs into links
|
||||
class SelectableLinkify extends StatelessWidget {
|
||||
const SelectableLinkify({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.linkifiers = defaultLinkifiers,
|
||||
this.onOpen,
|
||||
this.options = LinkifierUtil.linkifyOptions,
|
||||
// TextSpan
|
||||
this.style,
|
||||
this.linkStyle,
|
||||
// RichText
|
||||
this.textAlign,
|
||||
this.textDirection,
|
||||
this.minLines,
|
||||
this.maxLines,
|
||||
// SelectableText
|
||||
this.focusNode,
|
||||
this.textScaleFactor = 1.0,
|
||||
this.strutStyle,
|
||||
this.showCursor = false,
|
||||
this.autofocus = false,
|
||||
this.cursorWidth = 2.0,
|
||||
this.cursorRadius,
|
||||
this.cursorColor,
|
||||
this.dragStartBehavior = DragStartBehavior.start,
|
||||
this.enableInteractiveSelection = true,
|
||||
this.onTap,
|
||||
this.scrollPhysics,
|
||||
this.textWidthBasis,
|
||||
this.textHeightBehavior,
|
||||
this.cursorHeight,
|
||||
this.selectionControls,
|
||||
this.onSelectionChanged,
|
||||
this.contextMenuBuilder = _defaultContextMenuBuilder,
|
||||
});
|
||||
|
||||
/// Text to be linkified
|
||||
final String text;
|
||||
|
||||
/// The number of font pixels for each logical pixel
|
||||
final double textScaleFactor;
|
||||
|
||||
/// Linkifiers to be used for linkify
|
||||
final List<Linkifier> linkifiers;
|
||||
|
||||
/// Callback for tapping a link
|
||||
final LinkCallback? onOpen;
|
||||
|
||||
/// linkify's options.
|
||||
final LinkifyOptions options;
|
||||
|
||||
// TextSpan
|
||||
|
||||
/// Style for non-link text
|
||||
final TextStyle? style;
|
||||
|
||||
/// Style of link text
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
// Text.rich
|
||||
|
||||
/// How the text should be aligned horizontally.
|
||||
final TextAlign? textAlign;
|
||||
|
||||
/// Text direction of the text
|
||||
final TextDirection? textDirection;
|
||||
|
||||
/// The minimum number of lines to occupy when the content spans fewer lines.
|
||||
final int? minLines;
|
||||
|
||||
/// The maximum number of lines for the text to span, wrapping if necessary
|
||||
final int? maxLines;
|
||||
|
||||
/// The strut style used for the vertical layout
|
||||
final StrutStyle? strutStyle;
|
||||
|
||||
/// Defines how to measure the width of the rendered text.
|
||||
final TextWidthBasis? textWidthBasis;
|
||||
|
||||
// SelectableText.rich
|
||||
|
||||
/// Defines the focus for this widget.
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// Whether to show cursor
|
||||
final bool showCursor;
|
||||
|
||||
/// Whether this text field should focus itself if
|
||||
/// nothing else is already focused.
|
||||
final bool autofocus;
|
||||
|
||||
/// How thick the cursor will be
|
||||
final double cursorWidth;
|
||||
|
||||
/// How rounded the corners of the cursor should be
|
||||
final Radius? cursorRadius;
|
||||
|
||||
/// The color to use when painting the cursor
|
||||
final Color? cursorColor;
|
||||
|
||||
/// Determines the way that drag start behavior is handled
|
||||
final DragStartBehavior dragStartBehavior;
|
||||
|
||||
/// If true, then long-pressing this TextField will select text and show the cut/copy/paste menu,
|
||||
/// and tapping will move the text caret
|
||||
final bool enableInteractiveSelection;
|
||||
|
||||
/// Called when the user taps on this selectable text (not link)
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
final ScrollPhysics? scrollPhysics;
|
||||
|
||||
/// Defines how the paragraph will apply TextStyle.height to the ascent of
|
||||
/// the first line and descent of the last line.
|
||||
final TextHeightBehavior? textHeightBehavior;
|
||||
|
||||
/// How tall the cursor will be.
|
||||
final double? cursorHeight;
|
||||
|
||||
/// Optional delegate for building the text selection handles and toolbar.
|
||||
final TextSelectionControls? selectionControls;
|
||||
|
||||
/// Called when the user changes the selection of text (including the
|
||||
/// cursor location).
|
||||
final SelectionChangedCallback? onSelectionChanged;
|
||||
|
||||
final EditableTextContextMenuBuilder? contextMenuBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<LinkifyElement> elements = LinkifierUtil.linkify(text);
|
||||
return SelectableText.rich(
|
||||
buildTextSpan(
|
||||
elements,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.merge(style),
|
||||
onOpen: onOpen,
|
||||
linkStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.merge(style)
|
||||
.copyWith(
|
||||
color: Colors.blueAccent,
|
||||
decoration: TextDecoration.underline,
|
||||
)
|
||||
.merge(linkStyle),
|
||||
),
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
focusNode: focusNode,
|
||||
strutStyle: strutStyle,
|
||||
showCursor: showCursor,
|
||||
textScaleFactor: textScaleFactor,
|
||||
autofocus: autofocus,
|
||||
cursorWidth: cursorWidth,
|
||||
cursorRadius: cursorRadius,
|
||||
cursorColor: cursorColor,
|
||||
dragStartBehavior: dragStartBehavior,
|
||||
enableInteractiveSelection: enableInteractiveSelection,
|
||||
onTap: onTap,
|
||||
scrollPhysics: scrollPhysics,
|
||||
textWidthBasis: textWidthBasis,
|
||||
textHeightBehavior: textHeightBehavior,
|
||||
cursorHeight: cursorHeight,
|
||||
selectionControls: selectionControls,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
contextMenuBuilder: contextMenuBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _defaultContextMenuBuilder(
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) {
|
||||
return AdaptiveTextSelectionToolbar.editableText(
|
||||
editableTextState: editableTextState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LinkableSpan extends WidgetSpan {
|
||||
LinkableSpan({
|
||||
required MouseCursor mouseCursor,
|
||||
required InlineSpan inlineSpan,
|
||||
}) : super(
|
||||
child: MouseRegion(
|
||||
cursor: mouseCursor,
|
||||
child: Text.rich(
|
||||
inlineSpan,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Raw TextSpan builder for more control on the RichText
|
||||
TextSpan buildTextSpan(
|
||||
List<LinkifyElement> elements, {
|
||||
TextStyle? style,
|
||||
TextStyle? linkStyle,
|
||||
LinkCallback? onOpen,
|
||||
bool useMouseRegion = false,
|
||||
}) {
|
||||
return TextSpan(
|
||||
children: elements.map<InlineSpan>(
|
||||
(LinkifyElement element) {
|
||||
if (element is LinkableElement) {
|
||||
if (useMouseRegion) {
|
||||
return LinkableSpan(
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
inlineSpan: TextSpan(
|
||||
text: element.text,
|
||||
style: linkStyle,
|
||||
recognizer: onOpen != null
|
||||
? (TapGestureRecognizer()..onTap = () => onOpen(element))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return TextSpan(
|
||||
text: element.text,
|
||||
style: linkStyle,
|
||||
recognizer: onOpen != null
|
||||
? (TapGestureRecognizer()..onTap = () => onOpen(element))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (element is QuoteElement) {
|
||||
return TextSpan(
|
||||
text: element.text,
|
||||
style: style?.copyWith(
|
||||
backgroundColor: Palette.orangeAccent.withOpacity(0.3),
|
||||
),
|
||||
);
|
||||
} else if (element is EmphasisElement) {
|
||||
return TextSpan(
|
||||
text: element.text,
|
||||
style: style?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return TextSpan(
|
||||
text: element.text,
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
final RegExp _emphasisRegex = RegExp(
|
||||
r'\*(.*?)\*',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
class EmphasisLinkifier extends Linkifier {
|
||||
const EmphasisLinkifier();
|
||||
|
||||
@override
|
||||
List<LinkifyElement> parse(
|
||||
List<LinkifyElement> elements,
|
||||
LinkifyOptions options,
|
||||
) {
|
||||
final List<LinkifyElement> list = <LinkifyElement>[];
|
||||
|
||||
for (final LinkifyElement element in elements) {
|
||||
if (element is TextElement) {
|
||||
final RegExpMatch? match = _emphasisRegex.firstMatch(
|
||||
element.text.trimLeft(),
|
||||
);
|
||||
|
||||
if (element.text == '* * *' ||
|
||||
match == null ||
|
||||
match.group(0) == null ||
|
||||
match.group(1) == null) {
|
||||
list.add(element);
|
||||
} else {
|
||||
final String matchedText = match.group(1)!;
|
||||
final num pos =
|
||||
(element.text.indexOf(matchedText) - 1).clamp(0, double.infinity);
|
||||
final List<String> splitTexts = element.text.split(match.group(0)!);
|
||||
|
||||
int curPos = 0;
|
||||
bool added = false;
|
||||
|
||||
for (final String text in splitTexts) {
|
||||
list.addAll(parse(<LinkifyElement>[TextElement(text)], options));
|
||||
|
||||
curPos += text.length;
|
||||
|
||||
if (!added && curPos >= pos) {
|
||||
added = true;
|
||||
list.add(EmphasisElement(matchedText));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
list.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an element wrapped around '*'.
|
||||
@immutable
|
||||
class EmphasisElement extends LinkifyElement {
|
||||
EmphasisElement(super.text);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "EmphasisElement: '$text'";
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => equals(other);
|
||||
|
||||
@override
|
||||
bool equals(dynamic other) => other is EmphasisElement && super.equals(other);
|
||||
|
||||
@override
|
||||
int get hashCode => text.hashCode;
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export 'emphasis_linkifier.dart';
|
||||
export 'quote_linkifier.dart';
|
@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
final RegExp _quoteRegex = RegExp(
|
||||
r'(?=^>)(.*?)(?=\n|$)',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
class QuoteLinkifier extends Linkifier {
|
||||
const QuoteLinkifier();
|
||||
|
||||
@override
|
||||
List<LinkifyElement> parse(
|
||||
List<LinkifyElement> elements,
|
||||
LinkifyOptions options,
|
||||
) {
|
||||
final List<LinkifyElement> list = <LinkifyElement>[];
|
||||
|
||||
for (final LinkifyElement element in elements) {
|
||||
if (element is TextElement) {
|
||||
final RegExpMatch? match = _quoteRegex.firstMatch(
|
||||
element.text.trimLeft(),
|
||||
);
|
||||
|
||||
if (match == null) {
|
||||
list.add(element);
|
||||
} else {
|
||||
final String matchedText = match.group(0)!;
|
||||
final int pos = element.text.indexOf(matchedText);
|
||||
final List<String> splitTexts = element.text.split(matchedText);
|
||||
|
||||
int curPos = 0;
|
||||
bool added = false;
|
||||
|
||||
for (final String text in splitTexts) {
|
||||
list.addAll(parse(<TextElement>[TextElement(text)], options));
|
||||
curPos += text.length;
|
||||
if (!added && curPos >= pos) {
|
||||
added = true;
|
||||
list.add(QuoteElement(matchedText));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
list.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an element that starts with '>'.
|
||||
@immutable
|
||||
class QuoteElement extends LinkifyElement {
|
||||
QuoteElement(super.text);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "QuoteElement: '$text'";
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => equals(other);
|
||||
|
||||
@override
|
||||
bool equals(dynamic other) => other is QuoteElement && super.equals(other);
|
||||
|
||||
@override
|
||||
int get hashCode => text.hashCode;
|
||||
}
|
71
lib/screens/widgets/item_text.dart
Normal file
71
lib/screens/widgets/item_text.dart
Normal file
@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class ItemText extends StatelessWidget {
|
||||
const ItemText({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Item item;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PreferenceState prefState = context.read<PreferenceCubit>().state;
|
||||
final TextStyle style = TextStyle(
|
||||
fontSize: prefState.fontSize.fontSize,
|
||||
);
|
||||
final TextStyle linkStyle = TextStyle(
|
||||
fontSize: prefState.fontSize.fontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
color: Palette.orange,
|
||||
);
|
||||
if (item is Buildable) {
|
||||
return SelectableText.rich(
|
||||
buildTextSpan(
|
||||
(item as Buildable).elements,
|
||||
style: style,
|
||||
linkStyle: linkStyle,
|
||||
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
|
||||
),
|
||||
onTap: onTap,
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
contextMenuBuilder: (
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) =>
|
||||
contextMenuBuilder(
|
||||
context,
|
||||
editableTextState,
|
||||
item: item,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SelectableLinkify(
|
||||
text: item.text,
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
style: style,
|
||||
linkStyle: linkStyle,
|
||||
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
|
||||
onTap: onTap,
|
||||
contextMenuBuilder: (
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) =>
|
||||
contextMenuBuilder(
|
||||
context,
|
||||
editableTextState,
|
||||
item: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
|
@ -1,10 +1,12 @@
|
||||
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/styles/styles.dart';
|
||||
import 'package:memoize/memoize.dart';
|
||||
|
||||
class LinkView extends StatelessWidget {
|
||||
const LinkView({
|
||||
LinkView({
|
||||
super.key,
|
||||
required this.metadata,
|
||||
required this.url,
|
||||
@ -13,18 +15,19 @@ class LinkView extends StatelessWidget {
|
||||
required this.description,
|
||||
required this.onTap,
|
||||
required this.showMetadata,
|
||||
required this.showUrl,
|
||||
required bool showUrl,
|
||||
required this.bodyMaxLines,
|
||||
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',
|
||||
@ -42,14 +45,17 @@ class LinkView extends StatelessWidget {
|
||||
final TextStyle? bodyTextStyle;
|
||||
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) {
|
||||
static final double Function(double) _getTitleFontSize =
|
||||
memo1(_computeTitleFontSize);
|
||||
|
||||
static double _computeTitleFontSize(double width) {
|
||||
double size = width * 0.13;
|
||||
if (size > 15) {
|
||||
size = 15;
|
||||
@ -57,16 +63,26 @@ class LinkView extends StatelessWidget {
|
||||
return size;
|
||||
}
|
||||
|
||||
int computeTitleLines(double layoutHeight) {
|
||||
static final int Function(double) _getTitleLines = memo1(_computeTitleLines);
|
||||
|
||||
static int _computeTitleLines(double layoutHeight) {
|
||||
return layoutHeight >= 100 ? 2 : 1;
|
||||
}
|
||||
|
||||
int computeBodyLines(double layoutHeight) {
|
||||
int lines = 1;
|
||||
if (layoutHeight > 40) {
|
||||
lines += (layoutHeight - 40.0) ~/ 15.0;
|
||||
}
|
||||
return lines;
|
||||
static final int Function(int, bool, bool, String?) _getBodyLines =
|
||||
memo4(_computeBodyLines);
|
||||
|
||||
static int _computeBodyLines(
|
||||
int bodyMaxLines,
|
||||
bool showMetadata,
|
||||
bool showUrl,
|
||||
String? fontFamily,
|
||||
) {
|
||||
final int maxLines = bodyMaxLines -
|
||||
(showMetadata ? 1 : 0) -
|
||||
(showUrl ? 1 : 0) +
|
||||
(fontFamily == Font.ubuntuMono.name ? 1 : 0);
|
||||
return maxLines;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -76,15 +92,15 @@ class LinkView extends StatelessWidget {
|
||||
final double layoutWidth = constraints.biggest.width;
|
||||
final double layoutHeight = constraints.biggest.height;
|
||||
|
||||
final TextStyle titleFontSize = titleTextStyle ??
|
||||
final TextStyle titleFontStyle = titleTextStyle ??
|
||||
TextStyle(
|
||||
fontSize: computeTitleFontSize(layoutWidth),
|
||||
fontSize: _getTitleFontSize(layoutWidth),
|
||||
color: Palette.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
final TextStyle bodyFontSize = bodyTextStyle ??
|
||||
final TextStyle bodyFontStyle = bodyTextStyle ??
|
||||
TextStyle(
|
||||
fontSize: computeTitleFontSize(layoutWidth) - 1,
|
||||
fontSize: _getTitleFontSize(layoutWidth) - 1,
|
||||
color: Palette.grey,
|
||||
fontWeight: FontWeight.w400,
|
||||
);
|
||||
@ -96,7 +112,7 @@ class LinkView extends StatelessWidget {
|
||||
if (showMultiMedia)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 5,
|
||||
right: 8,
|
||||
top: 5,
|
||||
bottom: 5,
|
||||
),
|
||||
@ -112,7 +128,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,
|
||||
@ -124,22 +140,85 @@ 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),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.fontFamily ==
|
||||
Font.robotoSlab.name
|
||||
? 2
|
||||
: 4,
|
||||
),
|
||||
_buildBodyContainer(
|
||||
bodyFontSize,
|
||||
computeBodyLines(layoutHeight),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
title,
|
||||
style: titleFontStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: _getTitleLines(layoutHeight),
|
||||
),
|
||||
),
|
||||
if (showUrl && url.isNotEmpty)
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
'($readableUrl)',
|
||||
textAlign: TextAlign.left,
|
||||
style: titleFontStyle.copyWith(
|
||||
color: Palette.grey,
|
||||
fontSize: titleFontStyle.fontSize == null
|
||||
? 12
|
||||
: titleFontStyle.fontSize! - 4,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
overflow:
|
||||
bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showMetadata)
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
margin: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
metadata,
|
||||
textAlign: TextAlign.left,
|
||||
style: bodyFontStyle.copyWith(
|
||||
fontSize: bodyFontStyle.fontSize == null
|
||||
? 12
|
||||
: bodyFontStyle.fontSize! - 2,
|
||||
),
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
description,
|
||||
textAlign: TextAlign.left,
|
||||
style: bodyFontStyle,
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: _getBodyLines(
|
||||
bodyMaxLines,
|
||||
showMetadata,
|
||||
showUrl,
|
||||
Theme.of(context).textTheme.bodyMedium?.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -148,81 +227,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -79,9 +79,8 @@ class StoryTile extends StatelessWidget {
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: RichText(
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
text: TextSpan(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: <TextSpan>[
|
||||
TextSpan(
|
||||
text: story.title,
|
||||
@ -105,6 +104,7 @@ class StoryTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -6,7 +6,9 @@ export 'countdown_reminder.dart';
|
||||
export 'custom_chip.dart';
|
||||
export 'custom_circular_progress_indicator.dart';
|
||||
export 'custom_described_feature_overlay.dart';
|
||||
export 'custom_linkify/custom_linkify.dart';
|
||||
export 'custom_tab_bar.dart';
|
||||
export 'item_text.dart';
|
||||
export 'items_list_view.dart';
|
||||
export 'link_preview/link_preview.dart';
|
||||
export 'offline_banner.dart';
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
30
lib/utils/linkifier_util.dart
Normal file
30
lib/utils/linkifier_util.dart
Normal file
@ -0,0 +1,30 @@
|
||||
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 List<Linkifier> linkifiers = <Linkifier>[
|
||||
UrlLinkifier(),
|
||||
EmailLinkifier(),
|
||||
QuoteLinkifier(),
|
||||
EmphasisLinkifier(),
|
||||
];
|
||||
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
|
||||
|
||||
if (text.isEmpty) {
|
||||
return <LinkifyElement>[];
|
||||
}
|
||||
|
||||
if (linkifiers.isEmpty) {
|
||||
return list;
|
||||
}
|
||||
|
||||
for (final Linkifier linkifier in linkifiers) {
|
||||
list = linkifier.parse(list, linkifyOptions);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
export 'debouncer.dart';
|
||||
export 'html_util.dart';
|
||||
export 'link_util.dart';
|
||||
export 'linkifier_util.dart';
|
||||
export 'log_util.dart';
|
||||
export 'service_exception.dart';
|
||||
export 'throttle.dart';
|
||||
|
20
pubspec.lock
20
pubspec.lock
@ -332,14 +332,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.7.2+3"
|
||||
flutter_linkify:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_linkify
|
||||
sha256: c89fe74de985ec22f23d3538d2249add085a4f37ac1c29fd79e1a207efb81d63
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -569,7 +561,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.6.5"
|
||||
linkify:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: linkify
|
||||
sha256: bdfbdafec6cdc9cd0ebb333a868cafc046714ad508e48be8095208c54691d959
|
||||
@ -608,6 +600,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:
|
||||
@ -1359,4 +1359,4 @@ packages:
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.19.0 <3.0.0"
|
||||
flutter: ">=3.7.3"
|
||||
flutter: ">=3.7.5"
|
||||
|
24
pubspec.yaml
24
pubspec.yaml
@ -1,11 +1,11 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 1.0.12+90
|
||||
version: 1.3.1+100
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
flutter: "3.7.3"
|
||||
flutter: "3.7.5"
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^3.0.0
|
||||
@ -30,7 +30,6 @@ dependencies:
|
||||
flutter_fadein: ^2.0.0
|
||||
flutter_feather_icons: 2.0.0+1
|
||||
flutter_inappwebview: ^5.7.2+3
|
||||
flutter_linkify: ^5.0.2
|
||||
flutter_local_notifications: ^13.0.0
|
||||
flutter_secure_storage: ^8.0.0
|
||||
flutter_siri_suggestions: ^2.1.0
|
||||
@ -44,7 +43,9 @@ dependencies:
|
||||
http: ^0.13.5
|
||||
hydrated_bloc: ^9.1.0
|
||||
intl: ^0.18.0
|
||||
linkify: ^4.1.0
|
||||
logger: ^1.1.0
|
||||
memoize: ^3.0.0
|
||||
package_info_plus: ^3.0.3
|
||||
path: ^1.8.2
|
||||
path_provider: ^2.0.12
|
||||
@ -90,4 +91,21 @@ flutter:
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||
fonts:
|
||||
- family: RobotoSlab
|
||||
fonts:
|
||||
- asset: assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
|
||||
- asset: assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
|
||||
weight: 700
|
||||
- family: Ubuntu
|
||||
fonts:
|
||||
- asset: assets/fonts/ubuntu/Ubuntu-Regular.ttf
|
||||
- asset: assets/fonts/ubuntu/Ubuntu-Bold.ttf
|
||||
weight: 700
|
||||
- family: UbuntuMono
|
||||
fonts:
|
||||
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
|
||||
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
|
||||
weight: 700
|
||||
|
||||
|
||||
|
Submodule submodules/flutter updated: 9944297138...c07f788888
Reference in New Issue
Block a user