Compare commits

...

40 Commits

Author SHA1 Message Date
2af10391bc update story tile. (#175) 2023-02-27 16:48:53 -08:00
c420dd3ca4 correct spelling. (#174) 2023-02-27 15:16:10 -08:00
da7d0757cd add link to privacy policy. (#173) 2023-02-27 14:48:49 -08:00
32ae2087bc fix link button. (#171) 2023-02-26 23:03:48 -08:00
0b5329d050 bugfixes. (#170) 2023-02-26 15:08:18 -08:00
c375def289 bugfixes. (#169) 2023-02-26 12:12:11 -08:00
3469543c7b update action menu. (#168) 2023-02-26 02:40:11 -08:00
ab755581fd add favorite to action menu. (#167) 2023-02-25 23:16:55 -08:00
6b75eb8549 bump version. (#165) 2023-02-24 11:41:19 -08:00
36ded8a8e3 improve search experience. (#164) 2023-02-24 10:38:10 -08:00
582ac7b0be fix push notification. (#161) 2023-02-23 23:14:06 -08:00
e5e3391785 bump flutter version. (#160) 2023-02-23 01:04:44 -08:00
9159fe0fe1 add font customization. (#159) 2023-02-22 15:54:01 -08:00
7c51bad35e add shortcuts for wikipedia and wiktionary. (#157) 2023-02-22 13:37:32 -08:00
6836138d11 fix quote rendering. (#158) 2023-02-22 11:30:33 -08:00
2f71964277 linkifier cleanup. (#156) 2023-02-22 00:15:52 -08:00
c24c5c1b7a add formatting support (#155) 2023-02-21 23:40:25 -08:00
755b112382 remove isFirstLaunch val. (#153) 2023-02-12 20:22:38 -08:00
d44b64d249 fix feature discovery. (#152) 2023-02-12 19:39:51 -08:00
35ed917e66 improve onboarding experience. (#151) 2023-02-12 18:49:17 -08:00
15b75ef37c cleanup. (#149) 2023-02-11 19:44:54 -08:00
f39408fbcc fix time machine. (#148) 2023-02-11 04:17:43 -08:00
ca2f063297 update comment tile. (#146) 2023-02-11 02:30:51 -08:00
1ad231adbb fix offline mode. (#145) 2023-02-11 01:33:45 -08:00
60b09fd81e cleanup. (#143) 2023-02-11 00:39:30 -08:00
fe162208ca fix expand animation. (#142) 2023-02-10 14:08:31 -08:00
58139ba7a3 update commit_check.yml (#141) 2023-02-09 15:42:08 -08:00
33a31acbe2 update Fastfile. 2023-02-09 15:20:19 -08:00
0fcfcbb7e3 update Fastfile. (#140) 2023-02-09 15:12:00 -08:00
a98f52c90b update publish_ios.yml 2023-02-09 14:37:11 -08:00
8e8e48c44a update GitHub action. (#139) 2023-02-09 14:28:46 -08:00
603b7cc939 bump flutter to 3.7.3 (#138) 2023-02-09 11:27:03 -08:00
649fa33df3 fix err msg. (#137) 2023-02-09 00:19:34 -08:00
81d4a0f2df banner cleanup. (#136) 2023-02-08 23:44:15 -08:00
24112a471e add collapse/expand animation to comment tile. (#135) 2023-02-08 23:08:09 -08:00
c7824eaef3 bump flutter to 3.7.2 (#134) 2023-02-08 17:43:23 -08:00
c2b66d29c3 add sharing option. (#131) 2023-02-04 18:46:04 -08:00
e0a53e44b2 bump flutter to 3.7.1 (#129) 2023-02-01 15:19:06 -08:00
4cf8379db0 fix Story model. (#128) 2023-01-31 22:02:17 -08:00
c1c26bf0e0 fix preference model. (#127) 2023-01-31 18:19:34 -08:00
106 changed files with 2696 additions and 1698 deletions

View File

@ -11,15 +11,13 @@ jobs:
name: Check commit
runs-on: ubuntu-latest
timeout-minutes: 30
env:
FLUTTER_VERSION: "3.7.0"
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
- name: checkout all the submodules
uses: actions/checkout@v3
with:
flutter-version: '3.7.0'
channel: 'stable'
- run: flutter pub get
- run: flutter format --set-exit-if-changed .
- run: flutter analyze
- run: flutter test
submodules: recursive
- run: submodules/flutter/bin/flutter doctor
- run: submodules/flutter/bin/flutter pub get
- run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test
- run: submodules/flutter/bin/flutter analyze lib test integration_test
- run: submodules/flutter/bin/flutter test

View File

@ -20,21 +20,21 @@ jobs:
steps:
- name: Check out from git
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
submodules: recursive
- run: submodules/flutter/bin/flutter doctor
- run: submodules/flutter/bin/flutter pub get
- run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test
- run: submodules/flutter/bin/flutter analyze lib test integration_test
- run: submodules/flutter/bin/flutter test
# Configure ruby according to our .ruby-version
- name: Setup ruby & Bundler
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
# Set up flutter (feel free to adjust the version below)
- name: Setup flutter
uses: subosito/flutter-action@v2
with:
cache: true
flutter-version: 3.7.0
- run: flutter pub get
- run: flutter format --set-exit-if-changed .
- run: flutter analyze
# Start an ssh-agent that will provide the SSH key from the
# SSH_PRIVATE_KEY secret to `fastlane match`
- name: Setup SSH key
@ -43,8 +43,7 @@ jobs:
run: |
ssh-agent -a $SSH_AUTH_SOCK > /dev/null
ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}"
- name: Download dependencies
run: flutter pub get
- name: Build & Publish to TestFlight with Fastlane
env:
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}

18
assets/eula.md Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

48
assets/privacy_policy.md Normal file
View File

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

View File

@ -0,0 +1,3 @@
- Customization of tab bar.
- Option to enable swipe gesture for switching between tabs.
- Access to action menu from home screen.

View File

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

View File

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

View File

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

View File

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

View File

@ -49,7 +49,7 @@ latest_testflight_build_number
# Prep the xcodeproject from Flutter without building (`--config-only`)
sh(
"flutter", "build", "ios", "--config-only",
"/Users/runner/work/Hacki/Hacki/submodules/flutter/bin/flutter", "build", "ios", "--config-only",
"--release", "--no-pub", "--no-codesign",
"--build-number", new_build_number.to_s
)

View File

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

View File

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

View File

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

View File

@ -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';
@ -56,9 +60,19 @@ abstract class Constants {
'ʕ•́ᴥ•̀ʔっ',
'(ㆆ_ㆆ)',
].pickRandomly()!;
static final String magicWord = <String>[
'to be over the rainbow!',
'to infinity and beyond!',
'to see the future.',
].pickRandomly()!;
static final String errorMessage = 'Something went wrong...$sadFace';
static final String loginErrorMessage =
'''Failed to log in $sadFace, this could happen if your account requires a CAPTCHA, please try logging in inside a browser to see if this is the case, if so, you may try logging in here again later after CAPTCHA is no longer needed.''';
}
abstract class RegExpConstants {
static const String linkSuffix = r'(\)|])(.)*$';
static const String linkSuffix = r'(\)|]|,|\*)(.)*$';
static const String number = '[0-9]+';
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/screens/screens.dart';
/// Custom router.
@ -39,8 +40,8 @@ class CustomRouter {
appBar: AppBar(
title: const Text('Error'),
),
body: const Center(
child: Text('Something went wrong!'),
body: Center(
child: Text(Constants.errorMessage),
),
),
);

View File

@ -20,7 +20,7 @@ Future<void> setUpLocator() async {
Logger(
filter: CustomLogFilter(),
printer: LogUtil.logPrinter,
output: LogUtil.getLogOutput(logOutputFile),
output: LogUtil.logOutput(logOutputFile),
),
)
..registerSingleton<StoriesRepository>(StoriesRepository())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,8 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/styles/styles.dart';
extension ContextExtension on BuildContext {
T? tryRead<T>() {
@ -12,6 +14,31 @@ extension ContextExtension on BuildContext {
}
}
void showSnackBar({
required String content,
VoidCallback? action,
String? label,
}) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
backgroundColor: Palette.deepOrange,
content: Text(content),
action: action != null && label != null
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(this).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
);
}
void showErrorSnackBar() => showSnackBar(
content: Constants.errorMessage,
);
Rect? get rect {
final RenderBox? box = findRenderObject() as RenderBox?;
final Rect? rect =

View File

@ -2,17 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:share_plus/share_plus.dart';
extension StateExtension on State {
@ -21,22 +18,15 @@ extension StateExtension on State {
VoidCallback? action,
String? label,
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: Palette.deepOrange,
content: Text(content),
action: action != null && label != null
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
context.showSnackBar(
content: content,
action: action,
label: label,
);
}
void showErrorSnackBar() => context.showErrorSnackBar();
Future<void>? goToItemScreen({
required ItemScreenArgs args,
bool forceNewScreen = false,
@ -66,12 +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,
showSnackBar: showSnackBar,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
);
},
@ -82,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;
@ -98,32 +90,55 @@ extension StateExtension on State {
});
}
Future<void> onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
await locator
.get<StoriesRepository>()
.fetchItemBy(id: id)
.then((Item? item) {
if (mounted) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
}
});
void onFavTapped(Item item) {
final FavCubit favCubit = context.read<FavCubit>();
final bool isFav = favCubit.state.favIds.contains(item.id);
if (isFav) {
favCubit.removeFav(item.id);
} else {
LinkUtil.launch(link);
favCubit.addFav(item.id);
}
}
void onShareTapped(Item item, Rect? rect) {
Share.share(
'https://news.ycombinator.com/item?id=${item.id}',
sharePositionOrigin: rect,
);
Future<void> onShareTapped(Item item, Rect? rect) async {
late final String? linkToShare;
if (item.url.isNotEmpty) {
linkToShare = await showModalBottomSheet<String>(
context: context,
builder: (BuildContext context) {
return Container(
height: 140,
color: Theme.of(context).canvasColor,
child: Material(
child: Column(
children: <Widget>[
ListTile(
onTap: () => Navigator.pop(context, item.url),
title: const Text('Link to article'),
),
ListTile(
onTap: () => Navigator.pop(
context,
'https://news.ycombinator.com/item?id=${item.id}',
),
title: const Text('Link to HN'),
),
],
),
),
);
},
);
} else {
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
}
if (linkToShare != null) {
await Share.share(
linkToShare,
sharePositionOrigin: rect,
);
}
}
void onFlagTapped(Item item) {
@ -205,17 +220,11 @@ extension StateExtension on State {
}
void onLoginTapped() {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return LoginDialog(
usernameController: usernameController,
passwordController: passwordController,
showSnackBar: showSnackBar,
);
return const LoginDialog();
},
);
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,64 @@
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item/item.dart';
class Story extends Item {
const Story({
required super.descendants,
required super.id,
required super.score,
required super.time,
required super.by,
required super.title,
required super.type,
required super.url,
required super.text,
required super.kids,
required super.parts,
}) : super(
dead: false,
deleted: false,
parent: 0,
);
Story.empty() : super.empty();
Story.placeholder()
: super(
id: 0,
score: 0,
descendants: 0,
time: 1171872000,
by: 'Y Combinator',
title: 'Hacker News Guidelines',
url: Constants.guidelineLink,
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
Story.fromJson(super.json) : super.fromJson();
String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $postedDate | $descendants comment${descendants > 1 ? 's' : ''}''';
String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate''';
String get readableUrl {
final Uri url = Uri.parse(this.url);
final String authority = url.authority.replaceFirst('www.', '');
return authority;
}
@override
String toString() {
// final String prettyString =
// const JsonEncoder.withIndent(' ').convert(this);
// return 'Story $prettyString';
return 'Story $id';
}
}

View File

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

View File

@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:io';
import 'package:equatable/equatable.dart';
@ -13,26 +14,30 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
Preference<T> copyWith({required T? val});
static List<Preference<dynamic>> allPreferences = <Preference<dynamic>>[
// Order of these first three preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontSizePreference(),
TabOrderPreference(),
// Order of items below matters and
// reflects the order on settings screen.
const DisplayModePreference(),
const MetadataModePreference(),
const StoryUrlModePreference(),
const NotificationModePreference(),
const SwipeGesturePreference(),
const CollapseModePreference(),
NavigationModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
];
static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[
// Order of these first four preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontPreference(),
FontSizePreference(),
TabOrderPreference(),
// Order of items below matters and
// reflects the order on settings screen.
const DisplayModePreference(),
const MetadataModePreference(),
const StoryUrlModePreference(),
const NotificationModePreference(),
const SwipeGesturePreference(),
const CollapseModePreference(),
NavigationModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
],
);
@override
List<Object?> get props => <Object?>[key];
@ -61,6 +66,7 @@ const bool _collapseModeDefaultValue = true;
final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final int _fontSizeDefaultValue = FontSize.regular.index;
final int _fontDefaultValue = Font.roboto.index;
final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values);
@ -81,7 +87,7 @@ class SwipeGesturePreference extends BooleanPreference {
@override
String get subtitle =>
'''Enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
}
class NotificationModePreference extends BooleanPreference {
@ -118,6 +124,10 @@ class CollapseModePreference extends BooleanPreference {
@override
String get title => 'Tap Anywhere to Collapse';
@override
String get subtitle =>
'''if disabled, tap on the top of comment tile to collapse.''';
}
/// The value deciding whether or not the story
@ -317,6 +327,21 @@ class CommentsOrderPreference extends IntPreference {
String get title => 'Default comments order';
}
class FontPreference extends IntPreference {
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
@override
FontPreference copyWith({required int? val}) {
return FontPreference(val: val);
}
@override
String get key => 'font';
@override
String get title => 'Default font';
}
class FontSizePreference extends IntPreference {
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);

View File

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

View File

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

View File

@ -1,153 +0,0 @@
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart';
enum StoryType {
top('topstories'),
best('beststories'),
latest('newstories'),
ask('askstories'),
show('showstories');
const StoryType(this.path);
final String path;
String get label {
switch (this) {
case StoryType.top:
return 'TOP';
case StoryType.best:
return 'BEST';
case StoryType.latest:
return 'NEW';
case StoryType.ask:
return 'ASK';
case StoryType.show:
return 'SHOW';
}
}
static int convertToSettingsValue(List<StoryType> tabs) {
return int.parse(
tabs
.map((StoryType e) => e.index.toString())
.reduce((String value, String element) => '$value$element'),
);
}
}
class Story extends Item {
const Story({
required super.descendants,
required super.id,
required super.score,
required super.time,
required super.by,
required super.title,
required super.type,
required super.url,
required super.text,
required super.kids,
required super.parts,
}) : super(
dead: false,
deleted: false,
parent: 0,
);
Story.empty()
: super(
id: 0,
score: 0,
descendants: 0,
time: 0,
by: '',
title: '',
url: '',
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
Story.placeholder()
: super(
id: 0,
score: 0,
descendants: 0,
time: 1171872000,
by: 'Y Combinator',
title: 'Hacker News Guidelines',
url: Constants.guidelineLink,
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
Story.fromJson(super.json) : super.fromJson();
String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $postedDate | $descendants comment${descendants > 1 ? 's' : ''}''';
String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate''';
String get readableUrl {
final Uri url = Uri.parse(this.url);
final String authority = url.authority.replaceFirst('www.', '');
return authority;
}
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'descendants': descendants,
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
'parts': parts,
};
}
@override
String toString() {
// final String prettyString =
// const JsonEncoder.withIndent(' ').convert(this);
// return 'Story $prettyString';
return 'Story $id';
}
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
text,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
}

View File

@ -0,0 +1,34 @@
enum StoryType {
top('topstories'),
best('beststories'),
latest('newstories'),
ask('askstories'),
show('showstories');
const StoryType(this.path);
final String path;
String get label {
switch (this) {
case StoryType.top:
return 'TOP';
case StoryType.best:
return 'BEST';
case StoryType.latest:
return 'NEW';
case StoryType.ask:
return 'ASK';
case StoryType.show:
return 'SHOW';
}
}
static int convertToSettingsValue(List<StoryType> tabs) {
return int.parse(
tabs
.map((StoryType e) => e.index.toString())
.reduce((String value, String element) => '$value$element'),
);
}
}

View File

@ -2,10 +2,16 @@ import 'dart:async';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/post_repository.dart';
import 'package:hacki/repositories/postable_repository.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/repositories/preference_repository.dart';
import 'package:logger/logger.dart';
/// [AuthRepository] if for logging user in/out and performing actions
/// that require a logged in user such as [flag], [favorite], [upvote],
/// and [downvote].
///
/// For posting actions such as posting a comment, see [PostRepository].
class AuthRepository extends PostableRepository {
AuthRepository({
super.dio,
@ -18,8 +24,6 @@ class AuthRepository extends PostableRepository {
final PreferenceRepository _preferenceRepository;
final Logger _logger;
static const String _authority = 'news.ycombinator.com';
Future<bool> get loggedIn async => _preferenceRepository.loggedIn;
Future<String?> get username async => _preferenceRepository.username;
@ -30,7 +34,7 @@ class AuthRepository extends PostableRepository {
required String username,
required String password,
}) async {
final Uri uri = Uri.https(_authority, 'login');
final Uri uri = Uri.https(authority, 'login');
final PostDataMixin data = LoginPostData(
acct: username,
pw: password,
@ -64,7 +68,7 @@ class AuthRepository extends PostableRepository {
required int id,
required bool flag,
}) async {
final Uri uri = Uri.https(_authority, 'flag');
final Uri uri = Uri.https(authority, 'flag');
final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password;
final PostDataMixin data = FlagPostData(
@ -81,7 +85,7 @@ class AuthRepository extends PostableRepository {
required int id,
required bool favorite,
}) async {
final Uri uri = Uri.https(_authority, 'fave');
final Uri uri = Uri.https(authority, 'fave');
final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password;
final PostDataMixin data = FavoritePostData(
@ -98,7 +102,7 @@ class AuthRepository extends PostableRepository {
required int id,
required bool upvote,
}) async {
final Uri uri = Uri.https(_authority, 'vote');
final Uri uri = Uri.https(authority, 'vote');
final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password;
final PostDataMixin data = VotePostData(
@ -115,7 +119,7 @@ class AuthRepository extends PostableRepository {
required int id,
required bool downvote,
}) async {
final Uri uri = Uri.https(_authority, 'vote');
final Uri uri = Uri.https(authority, 'vote');
final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password;
final PostDataMixin data = VotePostData(

View File

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

View File

@ -7,6 +7,7 @@ import 'package:hacki/repositories/postable_repository.dart';
import 'package:hacki/repositories/preference_repository.dart';
import 'package:hacki/utils/utils.dart';
/// [PostRepository] is for posting contents to Hacker News.
class PostRepository extends PostableRepository {
PostRepository({super.dio, PreferenceRepository? storageRepository})
: _preferenceRepository =
@ -14,15 +15,13 @@ class PostRepository extends PostableRepository {
final PreferenceRepository _preferenceRepository;
static const String _authority = 'news.ycombinator.com';
Future<bool> comment({
required int parentId,
required String text,
}) async {
final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password;
final Uri uri = Uri.https(_authority, 'comment');
final Uri uri = Uri.https(authority, 'comment');
if (username == null || password == null) {
return false;
@ -54,7 +53,7 @@ class PostRepository extends PostableRepository {
return false;
}
final Response<List<int>> formResponse = await _getFormResponse(
final Response<List<int>> formResponse = await getFormResponse(
username: username,
password: password,
path: 'submitlink',
@ -69,7 +68,7 @@ class PostRepository extends PostableRepository {
final String? cookie =
formResponse.headers.value(HttpHeaders.setCookieHeader);
final Uri uri = Uri.https(_authority, 'r');
final Uri uri = Uri.https(authority, 'r');
final PostDataMixin data = SubmitPostData(
fnid: formValues['fnid']!,
fnop: formValues['fnop']!,
@ -97,7 +96,7 @@ class PostRepository extends PostableRepository {
return false;
}
final Response<List<int>> formResponse = await _getFormResponse(
final Response<List<int>> formResponse = await getFormResponse(
username: username,
password: password,
id: id,
@ -113,7 +112,7 @@ class PostRepository extends PostableRepository {
final String? cookie =
formResponse.headers.value(HttpHeaders.setCookieHeader);
final Uri uri = Uri.https(_authority, 'xedit');
final Uri uri = Uri.https(authority, 'xedit');
final PostDataMixin data = EditPostData(
hmac: formValues['hmac']!,
id: id,
@ -126,28 +125,4 @@ class PostRepository extends PostableRepository {
cookie: cookie,
);
}
Future<Response<List<int>>> _getFormResponse({
required String username,
required String password,
required String path,
int? id,
}) async {
final Uri uri = Uri.https(
_authority,
path,
<String, dynamic>{if (id != null) 'id': id.toString()},
);
final PostDataMixin data = FormPostData(
acct: username,
pw: password,
id: id,
);
return performPost(
uri,
data,
responseType: ResponseType.bytes,
validateStatus: (int? status) => status == HttpStatus.ok,
);
}
}

View File

@ -3,15 +3,23 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/auth_repository.dart';
import 'package:hacki/repositories/post_repository.dart';
import 'package:hacki/utils/service_exception.dart';
/// [PostableRepository] is solely for hosting functionalities shared between
/// [AuthRepository] and [PostRepository].
class PostableRepository {
PostableRepository({
Dio? dio,
this.authority = 'news.ycombinator.com',
}) : _dio = dio ?? Dio();
final Dio _dio;
@protected
final String authority;
@protected
Future<bool> performDefaultPost(
Uri uri,
@ -60,4 +68,29 @@ class PostableRepository {
throw ServiceException(e.message);
}
}
@protected
Future<Response<List<int>>> getFormResponse({
required String username,
required String password,
required String path,
int? id,
}) async {
final Uri uri = Uri.https(
authority,
path,
<String, dynamic>{if (id != null) 'id': id.toString()},
);
final PostDataMixin data = FormPostData(
acct: username,
pw: password,
id: id,
);
return performPost(
uri,
data,
responseType: ResponseType.bytes,
validateStatus: (int? status) => status == HttpStatus.ok,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
// ignore_for_file: comment_references
import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
@ -11,8 +9,8 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
@ -33,7 +31,7 @@ class ItemScreenArgs extends Equatable {
final List<Comment>? targetComments;
/// when a user is trying to view a sub-thread from a main thread, we don't
/// need to fetch comments from [StoryRepository] since we have some, if not
/// need to fetch comments from [StoriesRepository] since we have some, if not
/// all, comments cached in [CommentCache].
final bool useCommentCache;
@ -76,7 +74,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache,
),
),
@ -118,7 +116,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
targetAncestors: args.targetComments,
),
),
],
@ -175,16 +173,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (!isTesting) {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
}
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -239,12 +235,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<EditCubit>().onReplySubmittedSuccessfully();
context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) {
showSnackBar(
content: 'Something went wrong...'
'${Constants.sadFace}',
label: 'Okay',
action: ScaffoldMessenger.of(context).hideCurrentSnackBar,
);
showErrorSnackBar();
context.read<PostCubit>().reset();
}
},
@ -298,8 +289,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
),
@ -323,8 +312,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
splitViewEnabled: state.enabled,
expanded: state.expanded,
onZoomTap:
@ -364,8 +351,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
Theme.of(context).canvasColor.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
@ -378,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(
@ -401,15 +384,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
);
}
Future<bool> onFeatureDiscoveryDismissed() {
featureDiscoveryDismissThrottle.run(() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).clearSnackBars();
showSnackBar(content: 'Tap on icon to continue');
});
return Future<bool>.value(false);
}
void onFontSizeTapped() {
const Offset offset = Offset.zero;
final RenderBox overlay =
@ -475,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);
@ -519,7 +493,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
size: size,
deviceType: deviceType,
widthFactor: widthFactor,
onStoryLinkTapped: onStoryLinkTapped,
);
},
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
@ -15,21 +18,24 @@ class MorePopupMenu extends StatelessWidget {
super.key,
required this.item,
required this.isBlocked,
required this.showSnackBar,
required this.onStoryLinkTapped,
required this.onLoginTapped,
});
final Item item;
final bool isBlocked;
final void Function({
required String content,
VoidCallback? action,
String? label,
}) showSnackBar;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
static double? _cachedStoryHeight;
static double? _cachedCommentHeight;
static double get storyHeight {
return _cachedStoryHeight ??= Platform.isIOS ? 500 : 530;
}
static double get commentHeight {
return _cachedCommentHeight ??= Platform.isIOS ? 480 : 520;
}
@override
Widget build(BuildContext context) {
return BlocProvider<VoteCubit>(
@ -43,24 +49,26 @@ class MorePopupMenu extends StatelessWidget {
},
listener: (BuildContext context, VoteState voteState) {
if (voteState.status == VoteStatus.submitted) {
showSnackBar(content: 'Vote submitted successfully.');
context.showSnackBar(content: 'Vote submitted successfully.');
} else if (voteState.status == VoteStatus.canceled) {
showSnackBar(content: 'Vote canceled.');
context.showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) {
showSnackBar(content: 'Something went wrong...');
context.showErrorSnackBar();
} else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) {
showSnackBar(
context.showSnackBar(
content: "You can't downvote because you are karmaly broke.",
);
} else if (voteState.status == VoteStatus.failureNotLoggedIn) {
showSnackBar(
context.showSnackBar(
content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped,
label: 'Log in',
);
} else if (voteState.status == VoteStatus.failureBeHumble) {
showSnackBar(content: 'No voting on your own post! (;O´)o');
context.showSnackBar(
content: 'No voting on your own post! (;O´)o',
);
}
Navigator.pop(
@ -72,7 +80,7 @@ class MorePopupMenu extends StatelessWidget {
final bool upvoted = voteState.vote == Vote.up;
final bool downvoted = voteState.vote == Vote.down;
return Container(
height: item is Comment ? 430 : 450,
height: item is Comment ? commentHeight : storyHeight,
color: Theme.of(context).canvasColor,
child: Material(
color: Palette.transparent,
@ -92,6 +100,7 @@ class MorePopupMenu extends StatelessWidget {
state.user.description,
),
onTap: () {
Navigator.pop(context);
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
@ -116,15 +125,19 @@ class MorePopupMenu extends StatelessWidget {
linkStyle: const TextStyle(
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url),
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
onSearchUserTapped(context);
},
child: const Text(
'Search',
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
@ -167,6 +180,24 @@ class MorePopupMenu extends StatelessWidget {
),
onTap: context.read<VoteCubit>().downvote,
),
BlocBuilder<FavCubit, FavState>(
builder: (BuildContext context, FavState state) {
final bool isFav = state.favIds.contains(item.id);
return ListTile(
leading: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Palette.orange : null,
),
title: Text(
isFav ? 'Unfavorite' : 'Favorite',
),
onTap: () => Navigator.pop(
context,
MenuAction.fav,
),
);
},
),
ListTile(
leading: const Icon(FeatherIcons.share),
title: const Text(
@ -217,4 +248,45 @@ class MorePopupMenu extends StatelessWidget {
),
);
}
void onSearchUserTapped(BuildContext context) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return BlocProvider<SearchCubit>(
create: (_) => SearchCubit()
..addFilter(
PostedByFilter(
author: item.by,
),
),
child: Container(
height: MediaQuery.of(context).size.height - Dimens.pt120,
color: Theme.of(context).canvasColor,
margin: const EdgeInsets.only(top: Dimens.pt12),
child: Material(
child: Column(
children: <Widget>[
Container(
height: Dimens.pt4,
width: Dimens.pt24,
decoration: BoxDecoration(
color: Palette.grey,
borderRadius: BorderRadius.circular(Dimens.pt16),
),
),
const Expanded(
child: SearchScreen(
fromUserDialog: true,
),
)
],
),
),
),
);
},
);
}
}

View File

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

View File

@ -4,17 +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/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,27 +63,21 @@ class PollView extends StatelessWidget {
ScaffoldMessenger.of(context).clearSnackBars();
if (voteState.status == VoteStatus.submitted) {
showSnackBar(
context,
content: 'Vote submitted successfully.',
);
} else if (voteState.status == VoteStatus.canceled) {
showSnackBar(context, content: 'Vote canceled.');
showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) {
showSnackBar(
context,
content: 'Something went wrong...',
);
showErrorSnackBar();
} else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) {
showSnackBar(
context,
content: "You can't downvote because"
' you are karmaly broke.',
);
} else if (voteState.status ==
VoteStatus.failureNotLoggedIn) {
showSnackBar(
context,
content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped,
label: 'Log in',
@ -90,7 +85,6 @@ class PollView extends StatelessWidget {
} else if (voteState.status ==
VoteStatus.failureBeHumble) {
showSnackBar(
context,
content: 'No voting on your own post! (;O´)o',
);
}
@ -153,26 +147,4 @@ class PollView extends StatelessWidget {
},
);
}
void showSnackBar(
BuildContext context, {
required String content,
VoidCallback? action,
String? label,
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: Palette.deepOrange,
content: Text(content),
action: action != null && label != null
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,9 +50,7 @@ class _SubmitScreenState extends State<SubmitScreen> {
content: 'Post submitted successfully.',
);
} else if (state.status == SubmitStatus.failure) {
showSnackBar(
content: 'Something went wrong...',
);
showErrorSnackBar();
}
},
builder: (BuildContext context, SubmitState state) {

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:hacki/styles/styles.dart';
class CenteredText extends StatelessWidget {
const CenteredText({
super.key,
required this.text,
this.color = Palette.grey,
});
const CenteredText.deleted({Key? key})
: this(
key: key,
text: 'deleted',
);
const CenteredText.dead({Key? key})
: this(
key: key,
text: 'dead',
);
const CenteredText.blocked({Key? key})
: this(
key: key,
text: 'blocked',
);
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.only(
bottom: Dimens.pt12,
),
child: Text(
text,
style: TextStyle(
color: color,
),
),
),
);
}
}

View File

@ -1,23 +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/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,
@ -26,19 +22,24 @@ class CommentTile extends StatelessWidget {
this.opUsername,
this.actionable = true,
this.level = 0,
this.onTap,
});
final String? myUsername;
final String? opUsername;
final Comment comment;
final int level;
final bool actionable;
final FetchMode fetchMode;
final void Function(Comment)? onReplyTapped;
final void Function(Comment, Rect?)? onMoreTapped;
final void Function(Comment)? onEditTapped;
final void Function(Comment)? onRightMoreTapped;
final void Function(String) onStoryLinkTapped;
final FetchMode fetchMode;
/// Override for search screen.
final VoidCallback? onTap;
static final Map<int, Color> _colors = <int, Color>{};
@override
Widget build(BuildContext context) {
@ -119,6 +120,8 @@ class CommentTile extends StatelessWidget {
if (actionable) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
} else {
onTap?.call();
}
},
child: Column(
@ -157,136 +160,51 @@ class CommentTile extends StatelessWidget {
],
),
),
if (actionable && state.collapsed)
Center(
child: Padding(
padding: const EdgeInsets.only(
bottom: Dimens.pt12,
),
child: Text(
'collapsed '
'(${state.collapsedCount + 1})',
style: const TextStyle(
AnimatedSize(
duration: const Duration(milliseconds: 200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (actionable && state.collapsed)
CenteredText(
text:
'''collapsed (${state.collapsedCount + 1})''',
color: Palette.orangeAccent,
),
),
),
)
else if (comment.deleted)
const Center(
child: Padding(
padding: EdgeInsets.only(
bottom: Dimens.pt12,
),
child: Text(
'deleted',
style: TextStyle(
color: Palette.grey,
),
),
),
)
else if (comment.dead)
const Center(
child: Padding(
padding: EdgeInsets.only(
bottom: Dimens.pt12,
),
child: Text(
'dead',
style: TextStyle(
color: Palette.grey,
),
),
),
)
else if (blocklistState.blocklist.contains(comment.by))
const Center(
child: Padding(
padding: EdgeInsets.only(
bottom: Dimens.pt12,
),
child: Text(
'blocked',
style: TextStyle(
color: Palette.grey,
),
),
),
)
else
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt8,
right: Dimens.pt8,
top: Dimens.pt6,
bottom: Dimens.pt12,
),
child: comment is BuildableComment
? SelectableText.rich(
key: ValueKey<int>(comment.id),
buildTextSpan(
(comment as BuildableComment).elements,
style: TextStyle(
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
decoration: TextDecoration.underline,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
)
else if (comment.deleted)
const CenteredText.deleted()
else if (comment.dead)
const CenteredText.dead()
else if (blocklistState.blocklist
.contains(comment.by))
const CenteredText.blocked()
else
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt8,
right: Dimens.pt8,
top: Dimens.pt6,
bottom: Dimens.pt12,
),
child: SizedBox(
width: double.infinity,
child: ItemText(
key: ValueKey<int>(comment.id),
item: comment,
onTap: () {
if (onTap == null) {
_onTextTapped(context);
} else {
LinkUtil.launch(link.url);
onTap!.call();
}
},
),
onTap: () => onTextTapped(context),
)
: SelectableLinkify(
key: ValueKey<int>(comment.id),
text: comment.text,
style: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
onTap: () => onTextTapped(context),
),
),
],
),
if (!state.collapsed &&
fetchMode == FetchMode.lazy &&
comment.kids.isNotEmpty &&
!context
.read<CommentsCubit>()
.state
.commentIds
.contains(comment.kids.first) &&
!context
.read<CommentsCubit>()
.state
.onlyShowTargetComment)
),
if (_shouldShowLoadButton(context))
Center(
child: Padding(
padding: const EdgeInsets.symmetric(
@ -332,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;
@ -376,8 +295,6 @@ class CommentTile extends StatelessWidget {
);
}
static final Map<int, Color> _colors = <int, Color>{};
Color _getColor(int level) {
final int initialLevel = level;
if (_colors[initialLevel] != null) return _colors[initialLevel]!;
@ -406,7 +323,18 @@ class CommentTile extends StatelessWidget {
return color;
}
void onTextTapped(BuildContext context) {
bool _shouldShowLoadButton(BuildContext context) {
final CollapseState collapseState = context.read<CollapseCubit>().state;
final CommentsState? commentsState =
context.tryRead<CommentsCubit>()?.state;
return fetchMode == FetchMode.lazy &&
comment.kids.isNotEmpty &&
collapseState.collapsed == false &&
commentsState?.commentIds.contains(comment.kids.first) == false &&
commentsState?.onlyShowTargetComment == false;
}
void _onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/link_view.dart';
import 'package:hacki/screens/widgets/link_preview/web_analyzer.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:url_launcher/url_launcher.dart';
@ -119,7 +119,7 @@ class _LinkPreviewState extends State<LinkPreview> {
@override
void initState() {
_errorTitle = widget.errorTitle ?? 'Something went wrong!';
_errorTitle = widget.errorTitle ?? Constants.errorMessage;
_errorBody = widget.errorBody ??
'Oops! Unable to parse the url. We have '
'sent feedback to our developers & '

View File

@ -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),
),
),
),
],
),
),
);
}
}

View File

@ -2,7 +2,7 @@ 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/screens/widgets/link_preview/web_analyzer.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
class OfflineBanner extends StatelessWidget {

View File

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

View File

@ -1,10 +1,14 @@
export 'bloc_builder_3.dart';
export 'centered_text.dart';
export 'circle_tab_indicator.dart';
export 'comment_tile.dart';
export 'countdown_reminder.dart';
export 'custom_chip.dart';
export 'custom_circular_progress_indicator.dart';
export 'custom_described_feature_overlay.dart';
export 'custom_linkify/custom_linkify.dart';
export 'custom_tab_bar.dart';
export 'item_text.dart';
export 'items_list_view.dart';
export 'link_preview/link_preview.dart';
export 'offline_banner.dart';

View File

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

View File

@ -3,3 +3,4 @@ export 'custom_bloc_observer.dart';
export 'fetcher.dart';
export 'firebase_client.dart';
export 'local_notification.dart';
export 'web_analyzer.dart';

View File

@ -294,7 +294,7 @@ class WebAnalyzer {
// Kids of stories from search results are always empty, so here we try
// to fetch the story itself first and see if the kids are still empty.
if (kids.isEmpty) {
final Story? story = await storiesRepository.fetchStoryBy(storyId);
final Story? story = await storiesRepository.fetchStory(id: storyId);
if (story == null) return null;
@ -304,7 +304,7 @@ class WebAnalyzer {
}
final Comment? comment =
await storiesRepository.fetchCommentBy(id: kids.first);
await storiesRepository.fetchComment(id: kids.first);
return comment != null ? '${comment.by}: ${comment.text}' : null;
}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More