Compare commits

..

41 Commits

Author SHA1 Message Date
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
29e2f4163d fix offline mode. (#126) 2023-01-31 16:54:28 -08:00
c3de80015d fix PinnedStories (#125) 2023-01-31 16:36:58 -08:00
436cd9ce8b fix Item model. (#123) 2023-01-31 15:56:29 -08:00
efb326be68 refactor models. (#122) 2023-01-30 23:43:12 -08:00
047903fe24 refactor. (#121) 2023-01-30 22:46:29 -08:00
41068ddf89 cleanup. (#120) 2023-01-29 21:34:54 -08:00
196516ce85 fix logger. (#119) 2023-01-29 20:55:46 -08:00
7f647b127d enable swipe gesture. (#118) 2023-01-29 20:03:11 -08:00
a50a0874e7 fix logger. (#117) 2023-01-29 18:46:55 -08:00
b176be96fb Allow customizing tab bar. (#112) 2023-01-29 16:48:08 -08:00
1e5af07691 improve UX. (#110) 2023-01-25 21:47:05 -08:00
ecf8c902dc bump flutter and linter version. (#108) 2023-01-25 12:33:06 -08:00
128 changed files with 4586 additions and 3313 deletions

View File

@ -11,15 +11,13 @@ jobs:
name: Check commit
runs-on: ubuntu-latest
timeout-minutes: 30
env:
FLUTTER_VERSION: "3.3.10"
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
- name: checkout all the submodules
uses: actions/checkout@v3
with:
flutter-version: '3.3.10'
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.3.10
- 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 }}

View File

@ -1,4 +1,4 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml
include: package:very_good_analysis/analysis_options.3.1.0.yaml
linter:
rules:
parameter_assignments: false

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,4 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml
include: package:very_good_analysis/analysis_options.3.1.0.yaml
linter:
rules:
parameter_assignments: false

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

@ -3,6 +3,8 @@ PODS:
- Flutter
- ReachabilitySwift
- Flutter (1.0.0)
- flutter_email_sender (0.0.1):
- Flutter
- flutter_inappwebview (0.0.1):
- Flutter
- flutter_inappwebview/Core (= 0.0.1)
@ -12,7 +14,7 @@ PODS:
- OrderedSet (~> 5.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_secure_storage (3.3.1):
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_siri_suggestions (0.0.1):
- Flutter
@ -24,15 +26,17 @@ PODS:
- OrderedSet (5.0.0)
- package_info_plus (0.4.5):
- Flutter
- path_provider_ios (0.0.1):
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- ReachabilitySwift (5.0.0)
- receive_sharing_intent (0.0.1):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_ios (0.0.1):
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite (0.0.2):
- Flutter
- FMDB (>= 2.7.5)
@ -50,16 +54,17 @@ PODS:
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@ -78,6 +83,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
:path: Flutter
flutter_email_sender:
:path: ".symlinks/plugins/flutter_email_sender/ios"
flutter_inappwebview:
:path: ".symlinks/plugins/flutter_inappwebview/ios"
flutter_local_notifications:
@ -90,14 +97,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/integration_test/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_ios:
:path: ".symlinks/plugins/shared_preferences_ios/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
synced_shared_preferences:
@ -114,22 +121,23 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 51;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -360,6 +360,7 @@
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@ -413,6 +414,7 @@
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);

View File

@ -1,9 +1,9 @@
import UIKit
import Flutter
import workmanager
import shared_preferences_ios
import shared_preferences_foundation
import flutter_secure_storage
import path_provider_ios
import path_provider_foundation
import flutter_local_notifications
@UIApplicationMain
@ -26,8 +26,8 @@ import flutter_local_notifications
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
FLTSharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")!)
FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.pathprovider.PathProviderPlugin")!)
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")!)
PathProviderPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.pathprovider.PathProviderPlugin")!)
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin")!)
}

View File

@ -74,5 +74,7 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

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,8 +41,7 @@ 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(
@ -84,8 +83,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

@ -37,6 +37,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
on<StoryRead>(onStoryRead);
on<StoriesLoaded>(onStoriesLoaded);
on<StoriesDownload>(onDownload);
on<StoriesCancelDownload>(onCancelDownload);
on<StoryDownloaded>(onStoryDownloaded);
on<StoriesExitOffline>(onExitOffline);
on<StoriesPageSizeChanged>(onPageSizeChanged);
@ -55,75 +56,72 @@ 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,
) async {
_streamSubscription ??=
_preferenceCubit.stream.listen((PreferenceState event) {
final bool isComplexTile = event.showComplexStoryTile;
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
final bool isComplexTile = event.complexStoryTileEnabled;
final int pageSize = getPageSize(isComplexTile: isComplexTile);
if (pageSize != state.currentPageSize) {
add(StoriesPageSizeChanged(pageSize: pageSize));
}
});
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
final bool isComplexTile = _preferenceCubit.state.showComplexStoryTile;
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
final int pageSize = getPageSize(isComplexTile: isComplexTile);
emit(
const StoriesState.init().copyWith(
offlineReading: hasCachedStories,
offlineReading: hasCachedStories &&
// Only go into offline mode in the next session.
state.downloadStatus == StoriesDownloadStatus.initial,
currentPageSize: pageSize,
downloadStatus: state.downloadStatus,
storiesDownloaded: state.storiesDownloaded,
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));
});
}
}
@ -134,7 +132,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
) async {
emit(
state.copyWithStatusUpdated(
of: event.type,
type: event.type,
to: StoriesStatus.loading,
),
);
@ -142,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;
@ -212,7 +212,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} else {
emit(
state.copyWithStatusUpdated(
of: event.type,
type: event.type,
to: StoriesStatus.loaded,
),
);
@ -226,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,
),
@ -234,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(
@ -252,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);
}
@ -277,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(
@ -296,13 +301,41 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}
}
Future<void> onCancelDownload(
StoriesCancelDownload event,
Emitter<StoriesState> emit,
) async {
emit(
state.copyWith(
downloadStatus: StoriesDownloadStatus.canceled,
),
);
}
Future<void> fetchAndCacheStories(
Iterable<int> ids, {
required bool includingWebPage,
required bool isPrioritized,
}) async {
final List<StreamSubscription<Comment>> downloadStreams =
<StreamSubscription<Comment>>[];
for (final int id in ids) {
final Story? story = await _storiesRepository.fetchStoryBy(id);
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.fetchStory(id: id);
if (story == null) {
if (isPrioritized) {
@ -325,15 +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) => unawaited(
_offlineRepository.cacheComment(comment: comment),
),
)
.onDone(() => add(StoryDownloaded(skipped: false)));
(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(() {
_logger.d(
'''finished downloading story ${story.id} with ${story.descendants} comments''',
);
add(StoryDownloaded(skipped: false));
});
downloadStreams.add(downloadStream);
}
}
@ -374,7 +429,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesPageSizeChanged event,
Emitter<StoriesState> emit,
) async {
emit(const StoriesState.init());
add(StoriesInitialize());
}
@ -418,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

@ -46,6 +46,13 @@ class StoriesDownload extends StoriesEvent {
List<Object?> get props => <Object?>[includingWebPage];
}
class StoriesCancelDownload extends StoriesEvent {
StoriesCancelDownload();
@override
List<Object?> get props => <Object?>[];
}
class StoryDownloaded extends StoriesEvent {
StoryDownloaded({required this.skipped});

View File

@ -11,6 +11,7 @@ enum StoriesDownloadStatus {
downloading,
finished,
failure,
canceled,
}
class StoriesState extends Equatable {
@ -34,7 +35,6 @@ class StoriesState extends Equatable {
StoryType.latest: <Story>[],
StoryType.ask: <Story>[],
StoryType.show: <Story>[],
StoryType.jobs: <Story>[],
},
this.storyIdsByType = const <StoryType, List<int>>{
StoryType.top: <int>[],
@ -42,7 +42,6 @@ class StoriesState extends Equatable {
StoryType.latest: <int>[],
StoryType.ask: <int>[],
StoryType.show: <int>[],
StoryType.jobs: <int>[],
},
this.statusByType = const <StoryType, StoriesStatus>{
StoryType.top: StoriesStatus.initial,
@ -50,7 +49,6 @@ class StoriesState extends Equatable {
StoryType.latest: StoriesStatus.initial,
StoryType.ask: StoriesStatus.initial,
StoryType.show: StoriesStatus.initial,
StoryType.jobs: StoriesStatus.initial,
},
this.currentPageByType = const <StoryType, int>{
StoryType.top: 0,
@ -58,7 +56,6 @@ class StoriesState extends Equatable {
StoryType.latest: 0,
StoryType.ask: 0,
StoryType.show: 0,
StoryType.jobs: 0,
},
}) : offlineReading = false,
downloadStatus = StoriesDownloadStatus.initial,
@ -106,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>{
@ -123,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

@ -1,3 +1,5 @@
import 'package:hacki/extensions/extensions.dart';
abstract class Constants {
static const String endUserAgreementLink =
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45';
@ -12,6 +14,11 @@ abstract class Constants {
static const String sponsorLink = 'https://github.com/sponsors/Livinglist';
static const String guidelineLink =
'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';
static const String hackerNewsLogoPath = '$_imagePath/hacker_news_logo.png';
@ -22,6 +29,8 @@ abstract class Constants {
'$_imagePath/comment_tile_right_slide.png';
static const String commentTileTopTapPath =
'$_imagePath/comment_tile_top_tap.png';
static const String logFilename = 'hacki_log.txt';
static const String previousLogFileName = 'old_hacki_log.txt';
/// Feature ids for feature discovery.
static const String featureAddStoryToFavList = 'add_story_to_fav_list';
@ -29,16 +38,16 @@ abstract class Constants {
static const String featureLogIn = 'log_in';
static const String featurePinToTop = 'pin_to_top';
static const List<String> happyFaces = <String>[
static final String happyFace = <String>[
'(๑•̀ㅂ•́)و✧',
'( ͡• ͜ʖ ͡•)',
'( ͡~ ͜ʖ ͡°)',
'٩(˘◡˘)۶',
'(─‿‿─)',
'(¬‿¬)',
];
].pickRandomly()!;
static const List<String> sadFaces = <String>[
static final String sadFace = <String>[
'ಥ_ಥ',
'(╯°□°)╯︵ ┻━┻',
r'¯\_(ツ)_/¯',
@ -48,10 +57,12 @@ abstract class Constants {
'(ㆆ_ㆆ)',
'ʕ•́ᴥ•̀ʔっ',
'(ㆆ_ㆆ)',
];
].pickRandomly()!;
static final String errorMessage = 'Something went wrong...$sadFace';
}
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

@ -0,0 +1,41 @@
import 'dart:convert';
import 'dart:io';
import 'package:logger/logger.dart';
/// Writes the log output to a file.
/// Temporary solution to not being able to access
// ignore: comment_references
/// the original [FileOutput] from [Logger]
class CustomFileOutput extends LogOutput {
CustomFileOutput({
required this.file,
this.overrideExisting = false,
this.encoding = utf8,
});
final File file;
final bool overrideExisting;
final Encoding encoding;
IOSink? _sink;
@override
void init() {
_sink = file.openWrite(
mode: overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend,
encoding: encoding,
);
}
@override
void output(OutputEvent event) {
_sink?.writeAll(event.lines, '\n');
_sink?.writeln();
}
@override
Future<void> destroy() async {
await _sink?.flush();
await _sink?.close();
}
}

View File

@ -1,8 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:hacki/config/custom_log_filter.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
/// Global [GetIt.instance].
@ -10,8 +13,16 @@ final GetIt locator = GetIt.instance;
/// Set up [GetIt] locator.
Future<void> setUpLocator() async {
final File logOutputFile = await LogUtil.initLogFile();
locator
..registerSingleton<Logger>(Logger(filter: CustomLogFilter()))
..registerSingleton<Logger>(
Logger(
filter: CustomLogFilter(),
printer: LogUtil.logPrinter,
output: LogUtil.logOutput(logOutputFile),
),
)
..registerSingleton<StoriesRepository>(StoriesRepository())
..registerSingleton<PreferenceRepository>(PreferenceRepository())
..registerSingleton<SearchRepository>(SearchRepository())

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';
@ -89,6 +92,8 @@ class CommentsCubit extends Cubit<CommentsState> {
ids: targetParents!.last.kids,
level: targetParents.last.level + 1,
)
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
@ -106,38 +111,37 @@ 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) ?? 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 +177,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 +232,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),
),
),
);
@ -340,22 +340,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 +380,19 @@ 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<BuildableComment?> _toBuildableComment(Comment? comment) async {
if (comment == null) return null;
if (text.isEmpty) {
return <LinkifyElement>[];
}
final List<LinkifyElement> elements =
await compute<String, List<LinkifyElement>>(
LinkifierUtil.linkify,
comment.text,
);
if (linkifiers.isEmpty) {
return list;
}
final BuildableComment buildableComment =
BuildableComment.fromComment(comment, elements: elements);
for (final Linkifier linkifier in linkifiers) {
list = linkifier.parse(list, options);
}
return list;
return buildableComment;
}
@override

View File

@ -72,7 +72,6 @@ class CommentsState extends Equatable {
@override
List<Object?> get props => <Object?>[
item,
comments,
status,
fetchParentStatus,
order,
@ -80,5 +79,6 @@ class CommentsState extends Equatable {
onlyShowTargetComment,
offlineReading,
currentPage,
comments,
];
}

View File

@ -13,6 +13,7 @@ export 'reminder/reminder_cubit.dart';
export 'search/search_cubit.dart';
export 'split_view/split_view_cubit.dart';
export 'submit/submit_cubit.dart';
export 'tab/tab_cubit.dart';
export 'time_machine/time_machine_cubit.dart';
export 'user/user_cubit.dart';
export 'vote/vote_cubit.dart';

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

@ -42,9 +42,9 @@ class FavState extends Equatable {
@override
List<Object?> get props => <Object?>[
favIds,
favItems,
status,
currentPage,
favIds,
favItems,
];
}

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

@ -42,9 +42,9 @@ class HistoryState extends Equatable {
@override
List<Object?> get props => <Object?>[
submittedIds,
submittedItems,
status,
currentPage,
submittedIds,
submittedItems,
];
}

View File

@ -30,16 +30,16 @@ class NotificationCubit extends Cubit<NotificationState> {
_authBloc.stream.listen((AuthState authState) {
if (authState.isLoggedIn && authState.username != _username) {
// Get the user setting.
if (_preferenceCubit.state.showNotification) {
if (_preferenceCubit.state.notificationEnabled) {
Future<void>.delayed(const Duration(seconds: 2), init);
}
// Listen for setting changes in the future.
_preferenceCubit.stream.listen((PreferenceState prefState) {
final bool isActive = _timer?.isActive ?? false;
if (prefState.showNotification && !isActive) {
if (prefState.notificationEnabled && !isActive) {
init();
} else if (!prefState.showNotification) {
} else if (!prefState.notificationEnabled) {
_timer?.cancel();
}
});
@ -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(
@ -126,7 +126,8 @@ class NotificationCubit extends Cubit<NotificationState> {
}
Future<void> refresh() async {
if (_authBloc.state.isLoggedIn && _preferenceCubit.state.showNotification) {
if (_authBloc.state.isLoggedIn &&
_preferenceCubit.state.notificationEnabled) {
emit(
state.copyWith(
status: NotificationStatus.loading,
@ -158,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]));
}
@ -183,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(
@ -192,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>[];
@ -215,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

@ -77,11 +77,11 @@ class NotificationState extends Equatable {
@override
List<Object?> get props => <Object?>[
comments,
unreadCommentsIds,
allCommentsIds,
currentPage,
offset,
status,
comments,
unreadCommentsIds,
allCommentsIds,
];
}

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

@ -4,18 +4,23 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'preference_state.dart';
class PreferenceCubit extends Cubit<PreferenceState> {
PreferenceCubit({PreferenceRepository? storageRepository})
: _preferenceRepository =
storageRepository ?? locator.get<PreferenceRepository>(),
PreferenceCubit({
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(PreferenceState.init()) {
init();
}
final PreferenceRepository _preferenceRepository;
final Logger _logger;
void init() {
for (final BooleanPreference p
@ -32,6 +37,7 @@ class PreferenceCubit extends Cubit<PreferenceState> {
initPreference<int>(p).then<int?>((int? value) {
final Preference<dynamic> updatedPreference = p.copyWith(val: value);
emit(state.copyWithPreference(updatedPreference));
return null;
});
}
@ -50,17 +56,12 @@ class PreferenceCubit extends Cubit<PreferenceState> {
}
}
void toggle(BooleanPreference preference) {
final BooleanPreference updatedPreference =
preference.copyWith(val: !preference.val) as BooleanPreference;
emit(state.copyWithPreference(updatedPreference));
_preferenceRepository.setBool(preference.key, !preference.val);
}
void update<T>(Preference<T> preference, {required T to}) {
final T value = to;
final Preference<T> updatedPreference = preference.copyWith(val: value);
_logger.i('updating $preference to $value');
emit(state.copyWithPreference(updatedPreference));
switch (T) {

View File

@ -48,25 +48,44 @@ class PreferenceState extends Equatable {
.val;
}
bool get showNotification => _isOn<NotificationModePreference>();
bool get notificationEnabled => _isOn<NotificationModePreference>();
bool get showComplexStoryTile => _isOn<DisplayModePreference>();
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get showWebFirst => _isOn<NavigationModePreference>();
bool get webFirstEnabled => _isOn<NavigationModePreference>();
bool get showEyeCandy => _isOn<EyeCandyModePreference>();
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get useTrueDark => _isOn<TrueDarkModePreference>();
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();
bool get useReader => _isOn<ReaderModePreference>();
bool get readerEnabled => _isOn<ReaderModePreference>();
bool get markReadStories => _isOn<MarkReadStoriesModePreference>();
bool get markReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
bool get showMetadata => _isOn<MetadataModePreference>();
bool get metadataEnabled => _isOn<MetadataModePreference>();
bool get showUrl => _isOn<StoryUrlModePreference>();
bool get urlEnabled => _isOn<StoryUrlModePreference>();
bool get tapAnywhereToCollapse => _isOn<CollapseModePreference>();
bool get tapAnywhereToCollapseEnabled => _isOn<CollapseModePreference>();
bool get swipeGestureEnabled => _isOn<SwipeGesturePreference>();
List<StoryType> get tabs {
final String result =
preferences.singleWhereType<TabOrderPreference>().val.toString();
final List<int> tabIndexes = List<int>.generate(
result.length,
(int index) => result.codeUnitAt(index) - 48,
);
final List<StoryType> tabs = tabIndexes
.map((int index) => StoryType.values.elementAt(index))
.toList();
if (tabs.length < StoryType.values.length) {
tabs.insert(0, StoryType.values.first);
}
return tabs;
}
FetchMode get fetchMode => FetchMode.values
.elementAt(preferences.singleWhereType<FetchModePreference>().val);
@ -77,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

@ -0,0 +1,46 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:logger/logger.dart';
part 'tab_state.dart';
class TabCubit extends Cubit<TabState> {
TabCubit({
required PreferenceCubit preferenceCubit,
Logger? logger,
}) : _preferenceCubit = preferenceCubit,
_logger = logger ?? locator.get<Logger>(),
super(TabState.init());
final PreferenceCubit _preferenceCubit;
final Logger _logger;
void init() {
final List<StoryType> tabs = _preferenceCubit.state.tabs;
_logger.i('updating tabs to $tabs');
emit(state.copyWith(tabs: tabs));
}
void update(int startIndex, int endIndex) {
_logger.d('updating ${state.tabs} by moving $startIndex to $endIndex');
final StoryType tab = state.tabs.elementAt(startIndex);
final List<StoryType> updatedTabs = List<StoryType>.from(state.tabs)
..insert(endIndex, tab)
..removeAt(startIndex < endIndex ? startIndex : startIndex + 1);
_logger.d(updatedTabs);
emit(state.copyWith(tabs: updatedTabs));
// Check to make sure there's no duplicate.
if (updatedTabs.toSet().length == StoryType.values.length) {
_preferenceCubit.update<int>(
TabOrderPreference(),
to: StoryType.convertToSettingsValue(updatedTabs),
);
}
}
}

View File

@ -0,0 +1,18 @@
part of 'tab_cubit.dart';
class TabState extends Equatable {
const TabState({required this.tabs});
TabState.init() : tabs = <StoryType>[];
final List<StoryType> tabs;
TabState copyWith({
List<StoryType>? tabs,
}) {
return TabState(tabs: tabs ?? this.tabs);
}
@override
List<Object?> get props => <Object?>[tabs];
}

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 =
@ -22,12 +49,12 @@ extension ContextExtension on BuildContext {
static double _screenWidth = 0;
static double _storyTileHeight = 0;
static int _storyTileMaxLines = 4;
static const double _screenWidthLowerBound = 430,
_screenWidthUpperBound = 850,
_picHeightLowerBound = 110,
_picHeightUpperBound = 128,
_smallPicHeight = 100,
_picHeightFactor = 0.3;
static const double _screenWidthLowerBound = 430;
static const double _screenWidthUpperBound = 850;
static const double _picHeightLowerBound = 110;
static const double _picHeightUpperBound = 128;
static const double _smallPicHeight = 100;
static const double _picHeightFactor = 0.3;
double get storyTileHeight {
final double screenWidth =

View File

@ -1,7 +1,8 @@
import 'dart:developer' as dev;
import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart';
extension ObjectExtension on Object {
void log({String identifier = ''}) {
dev.log('$identifier ${toString()}', level: 2000);
locator.get<Logger>().d('$identifier ${toString()}');
}
}

View File

@ -1,9 +1,19 @@
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 {
void showSnackBar({
@ -11,22 +21,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.bodyText1?.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,
@ -44,4 +47,202 @@ extension StateExtension on State {
return Future<void>.value();
}
void onMoreTapped(Item item, Rect? rect) {
HapticFeedback.lightImpact();
if (item.dead || item.deleted) {
return;
}
final bool isBlocked =
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
showModalBottomSheet<MenuAction>(
context: context,
builder: (BuildContext context) {
return MorePopupMenu(
item: item,
isBlocked: isBlocked,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
);
},
).then((MenuAction? action) {
if (action != null) {
switch (action) {
case MenuAction.upvote:
break;
case MenuAction.downvote:
break;
case MenuAction.share:
onShareTapped(item, rect);
break;
case MenuAction.flag:
onFlagTapped(item);
break;
case MenuAction.block:
onBlockTapped(item, isBlocked: isBlocked);
break;
case MenuAction.cancel:
break;
}
}
});
}
Future<void> onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
await locator
.get<StoriesRepository>()
.fetchItem(id: id)
.then((Item? item) {
if (mounted) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
}
});
} else {
LinkUtil.launch(link);
}
}
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) {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Flag this comment?'),
content: Text(
'Flag this comment posted by ${item.by}?',
style: const TextStyle(
color: Palette.grey,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Yes',
),
),
],
);
},
).then((bool? yesTapped) {
if (yesTapped ?? false) {
context.read<AuthBloc>().add(AuthFlag(item: item));
showSnackBar(content: 'Comment flagged!');
}
});
}
void onBlockTapped(Item item, {required bool isBlocked}) {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('${isBlocked ? 'Unblock' : 'Block'} this user?'),
content: Text(
'Do you want to ${isBlocked ? 'unblock' : 'block'} ${item.by}'
' and ${isBlocked ? 'display' : 'hide'} '
'comments posted by this user?',
style: const TextStyle(
color: Palette.grey,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Yes',
),
),
],
);
},
).then((bool? yesTapped) {
if (yesTapped ?? false) {
if (isBlocked) {
context.read<BlocklistCubit>().removeFromBlocklist(item.by);
} else {
context.read<BlocklistCubit>().addToBlocklist(item.by);
}
showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
}
});
}
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,
);
},
);
}
}

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,59 @@ extension WidgetModifier on Widget {
child: this,
);
}
Widget contextMenuBuilder(
BuildContext context,
EditableTextState editableTextState, {
required BuildableComment comment,
}) {
final Iterable<EmphasisElement> emphasisElements =
comment.elements.whereType<EmphasisElement>();
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 = comment.text.substring(start, end);
int count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start + count * 2).clamp(0, comment.text.length);
final int e = (end + count * 2).clamp(0, comment.text.length);
selectedText = comment.text.substring(s, e);
count++;
}
count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start - count * 2).clamp(0, comment.text.length);
final int e = (end - count * 2).clamp(0, comment.text.length);
selectedText = comment.text.substring(s, e);
count++;
}
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

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -21,6 +22,7 @@ import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
import 'package:shared_preferences/shared_preferences.dart';
@ -36,11 +38,30 @@ final BehaviorSubject<String?> siriSuggestionSubject =
late final bool isTesting;
void notificationReceiver(NotificationResponse details) =>
selectNotificationSubject.add(details.payload);
Future<void> main({bool testing = false}) async {
WidgetsFlutterBinding.ensureInitialized();
isTesting = testing;
final Directory tempDir = await getTemporaryDirectory();
final String tempPath = tempDir.path;
Hive.init(tempPath);
await setUpLocator();
EquatableConfig.stringify = true;
FlutterError.onError = (FlutterErrorDetails details) {
locator.get<Logger>().e(
details.summary,
details.exceptionAsString(),
details.stack,
);
};
final HydratedStorage storage = await HydratedStorage.build(
storageDirectory: kIsWeb
? HydratedStorage.webStorageDirectory
@ -58,8 +79,8 @@ Future<void> main({bool testing = false}) async {
FlutterLocalNotificationsPlugin();
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const IOSInitializationSettings initializationSettingsIOS =
IOSInitializationSettings();
const DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings();
const InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
@ -67,7 +88,8 @@ Future<void> main({bool testing = false}) async {
);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onSelectNotification: selectNotificationSubject.add,
onDidReceiveBackgroundNotificationResponse: notificationReceiver,
onDidReceiveNotificationResponse: notificationReceiver,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
@ -102,24 +124,23 @@ Future<void> main({bool testing = false}) async {
);
}
final Directory tempDir = await getTemporaryDirectory();
final String tempPath = tempDir.path;
Hive.init(tempPath);
await setUpLocator();
final AdaptiveThemeMode? savedThemeMode = await AdaptiveTheme.getThemeMode();
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();
HydratedBloc.storage = storage;
runApp(
HackiApp(
savedThemeMode: savedThemeMode,
trueDarkMode: trueDarkMode,
font: font,
),
);
}
@ -129,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 =
@ -201,15 +224,22 @@ class HackiApp extends StatelessWidget {
lazy: false,
create: (BuildContext context) => EditCubit(),
),
BlocProvider<TabCubit>(
create: (BuildContext context) => TabCubit(
preferenceCubit: context.read<PreferenceCubit>(),
)..init(),
)
],
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) {
@ -217,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(),
@ -228,9 +259,9 @@ class HackiApp extends StatelessWidget {
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen:
(PreferenceState previous, PreferenceState current) =>
previous.useTrueDark != current.useTrueDark,
previous.trueDarkEnabled != current.trueDarkEnabled,
builder: (BuildContext context, PreferenceState prefState) {
final bool useTrueDark = prefState.useTrueDark &&
final bool useTrueDark = prefState.trueDarkEnabled &&
(mode == AdaptiveThemeMode.dark ||
(mode == AdaptiveThemeMode.system &&
SchedulerBinding

View File

@ -1,7 +1,9 @@
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/models/comment.dart';
import 'package:hacki/models/models.dart';
import 'package:linkify/linkify.dart';
/// [BuildableComment] is a subtype of [Comment] which stores
/// the corresponding [LinkifyElement] for faster widget building.
class BuildableComment extends Comment {
BuildableComment({
required super.id,

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:hacki/models/item.dart';
class Comment extends Item {
@ -22,23 +20,7 @@ class Comment extends Item {
type: '',
);
Comment.fromJson(Map<String, dynamic> json, {this.level = 0})
: super(
id: json['id'] as int? ?? 0,
time: json['time'] as int? ?? 0,
by: json['by'] as String? ?? '',
text: json['text'] as String? ?? '',
kids: (json['kids'] as List<dynamic>?)?.cast<int>() ?? <int>[],
parent: json['parent'] as int? ?? 0,
deleted: json['deleted'] as bool? ?? false,
score: json['score'] as int? ?? 0,
descendants: 0,
dead: json['dead'] as bool? ?? false,
parts: <int>[],
title: '',
url: '',
type: '',
);
Comment.fromJson(super.json, {this.level = 0}) : super.fromJson();
final int level;
@ -60,41 +42,5 @@ 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
String toString() {
final String prettyString =
const JsonEncoder.withIndent(' ').convert(this);
return 'Comment $prettyString';
}
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
bool? get stringify => false;
}

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

@ -1,6 +1,10 @@
import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.dart';
import 'package:hacki/models/comment.dart';
import 'package:hacki/models/poll_option.dart';
import 'package:hacki/models/story.dart';
/// [Item] is the base type of [Story], [Comment] and [PollOption].
class Item extends Equatable {
const Item({
required this.id,
@ -44,11 +48,11 @@ class Item extends Equatable {
title = json['title'] as String? ?? '',
text = json['text'] as String? ?? '',
url = json['url'] as String? ?? '',
kids = <int>[],
kids = (json['kids'] as List<dynamic>?)?.cast<int>() ?? <int>[],
dead = json['dead'] as bool? ?? false,
deleted = json['deleted'] as bool? ?? false,
parent = json['parent'] as int? ?? 0,
parts = <int>[],
parts = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
type = json['type'] as String? ?? '';
final int id;
@ -97,6 +101,7 @@ class Item extends Equatable {
'deleted': deleted,
'type': type,
'parts': parts,
'parent': parent,
};
}

View File

@ -2,6 +2,7 @@ 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';
@ -9,4 +10,5 @@ export 'post_data.dart';
export 'preference.dart';
export 'search_params.dart';
export 'story.dart';
export 'story_type.dart';
export 'user.dart';

View File

@ -24,41 +24,11 @@ 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(Map<String, dynamic> json)
PollOption.fromJson(super.json)
: ratio = 0,
super(
descendants: 0,
id: json['id'] as int? ?? 0,
score: json['score'] as int? ?? 0,
time: json['time'] as int? ?? 0,
by: json['by'] as String? ?? '',
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
kids: <int>[],
text: json['text'] as String? ?? '',
dead: json['dead'] as bool? ?? false,
deleted: json['deleted'] as bool? ?? false,
type: json['type'] as String? ?? '',
parts: <int>[],
parent: 0,
);
super.fromJson();
final double ratio;
@ -82,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,
};
}
@ -105,22 +63,4 @@ class PollOption extends Item {
const JsonEncoder.withIndent(' ').convert(this);
return 'PollOption $prettyString';
}
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
}

View File

@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:io';
import 'package:equatable/equatable.dart';
@ -13,22 +14,30 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
Preference<T> copyWith({required T? val});
static List<Preference<dynamic>> allPreferences = <Preference<dynamic>>[
FetchModePreference(),
CommentsOrderPreference(),
FontSizePreference(),
// order here reflects the order on settings screen.
const DisplayModePreference(),
const MetadataModePreference(),
const StoryUrlModePreference(),
const NotificationModePreference(),
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];
@ -43,8 +52,9 @@ abstract class IntPreference extends Preference<int> {
}
const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _navigationModeDefaultValueIOS = true;
const bool _navigationModeDefaultValueIOS = false;
const bool _navigationModeDefaultValueAndroid = false;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
@ -52,10 +62,33 @@ const bool _readerModeDefaultValue = true;
const bool _markReadStoriesModeDefaultValue = true;
const bool _metadataModeDefaultValue = true;
const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = false;
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);
class SwipeGesturePreference extends BooleanPreference {
const SwipeGesturePreference({bool? val})
: super(val: val ?? _swipeGestureModeDefaultValue);
@override
SwipeGesturePreference copyWith({required bool? val}) {
return SwipeGesturePreference(val: val);
}
@override
String get key => 'swipeGestureMode';
@override
String get title => 'Enable Swipe Gesture';
@override
String get subtitle =>
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
}
class NotificationModePreference extends BooleanPreference {
const NotificationModePreference({bool? val})
@ -91,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
@ -290,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);
@ -304,3 +356,18 @@ class FontSizePreference extends IntPreference {
@override
String get title => 'Default font size';
}
class TabOrderPreference extends IntPreference {
TabOrderPreference({int? val}) : super(val: val ?? _tabOrderDefaultValue);
@override
TabOrderPreference copyWith({required int? val}) {
return TabOrderPreference(val: val);
}
@override
String get key => 'tabOrder';
@override
String get title => 'Tab order';
}

View File

@ -1,36 +1,6 @@
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart';
enum StoryType {
top('topstories'),
best('beststories'),
latest('newstories'),
ask('askstories'),
show('showstories'),
jobs('jobstories');
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';
case StoryType.jobs:
return 'JOBS';
}
}
}
class Story extends Item {
const Story({
required super.descendants,
@ -50,23 +20,7 @@ class Story extends Item {
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.empty() : super.empty();
Story.placeholder()
: super(
@ -86,23 +40,7 @@ class Story extends Item {
type: '',
);
Story.fromJson(Map<String, dynamic> json)
: super(
descendants: json['descendants'] as int? ?? 0,
id: json['id'] as int? ?? 0,
score: json['score'] as int? ?? 0,
time: json['time'] as int? ?? 0,
by: json['by'] as String? ?? '',
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
kids: (json['kids'] as List<dynamic>?)?.cast<int>() ?? <int>[],
text: json['text'] as String? ?? '',
dead: json['dead'] as bool? ?? false,
deleted: json['deleted'] as bool? ?? false,
type: json['type'] as String? ?? '',
parts: (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
parent: 0,
);
Story.fromJson(super.json) : super.fromJson();
String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $postedDate | $descendants comment${descendants > 1 ? 's' : ''}''';
@ -116,25 +54,6 @@ class Story extends Item {
return authority;
}
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'descendants': descendants,
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
'parts': parts,
};
}
@override
String toString() {
// final String prettyString =
@ -142,23 +61,4 @@ class Story extends Item {
// return 'Story $prettyString';
return 'Story $id';
}
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
text,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
}

View File

@ -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

@ -1,27 +1,29 @@
import 'dart:async';
import 'package:dio/dio.dart';
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({
Dio? dio,
super.dio,
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(dio: dio);
_logger = logger ?? locator.get<Logger>();
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;
@ -32,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,
@ -66,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(
@ -83,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(
@ -100,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(
@ -117,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,23 +7,21 @@ 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({Dio? dio, PreferenceRepository? storageRepository})
PostRepository({super.dio, PreferenceRepository? storageRepository})
: _preferenceRepository =
storageRepository ?? locator.get<PreferenceRepository>(),
super(dio: dio);
storageRepository ?? locator.get<PreferenceRepository>();
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;
@ -55,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',
@ -70,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']!,
@ -98,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,
@ -114,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,
@ -127,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();

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

@ -0,0 +1,335 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/home/widgets/widgets.dart';
import 'package:hacki/screens/screens.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';
import 'package:logger/logger.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
static const String routeName = '/';
static Route<dynamic> route() {
return MaterialPageRoute<HomeScreen>(
settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => const HomeScreen(),
);
}
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware {
late final TabController tabController;
late final StreamSubscription<String> intentDataStreamSubscription;
late final StreamSubscription<String?> notificationStreamSubscription;
late final StreamSubscription<String?> siriSuggestionStreamSubscription;
static final int tabLength = StoryType.values.length + 1;
@override
void didPopNext() {
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed(
const Duration(milliseconds: 500),
locator.get<CommentCache>().resetComments,
);
}
}
@override
void initState() {
super.initState();
// This is for testing only.
// FeatureDiscovery.clearPreferences(context, <String>[
// Constants.featureLogIn,
// Constants.featureAddStoryToFavList,
// Constants.featureOpenStoryInWebView,
// Constants.featurePinToTop,
// ]);
ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped);
intentDataStreamSubscription =
ReceiveSharingIntent.getTextStream().listen(onShareExtensionTapped);
if (!selectNotificationSubject.hasListener) {
notificationStreamSubscription =
selectNotificationSubject.stream.listen(onNotificationTapped);
}
if (!siriSuggestionSubject.hasListener) {
siriSuggestionStreamSubscription =
siriSuggestionSubject.stream.listen(onSiriSuggestionTapped);
}
SchedulerBinding.instance
..addPostFrameCallback((_) {
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featureLogIn,
},
);
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
if (route == null) return;
locator
.get<RouteObserver<ModalRoute<dynamic>>>()
.subscribe(this, route);
});
tabController = TabController(length: tabLength, vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final DeviceScreenType deviceType =
getDeviceType(MediaQuery.of(context).size);
if (context.read<StoriesBloc>().deviceScreenType != deviceType) {
context.read<StoriesBloc>().deviceScreenType = deviceType;
context.read<StoriesBloc>().add(StoriesInitialize());
}
}
@override
void dispose() {
tabController.dispose();
intentDataStreamSubscription.cancel();
notificationStreamSubscription.cancel();
siriSuggestionStreamSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final BlocBuilder<PreferenceCubit, PreferenceState> homeScreen =
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
previous.metadataEnabled != current.metadataEnabled ||
previous.swipeGestureEnabled != current.swipeGestureEnabled,
builder: (BuildContext context, PreferenceState preferenceState) {
return DefaultTabController(
length: tabLength,
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: PreferredSize(
preferredSize: const Size(
Dimens.zero,
Dimens.pt40,
),
child: Column(
children: <Widget>[
SizedBox(
height: MediaQuery.of(context).padding.top - Dimens.pt8,
),
Theme(
data: ThemeData(
highlightColor: Palette.transparent,
splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor,
),
child: CustomTabBar(
tabController: tabController,
),
),
],
),
),
body: BlocBuilder<TabCubit, TabState>(
builder: (BuildContext context, TabState state) {
return TabBarView(
physics: preferenceState.swipeGestureEnabled
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics(),
controller: tabController,
children: <Widget>[
for (final StoryType type in state.tabs)
StoriesListView(
key: ValueKey<StoryType>(type),
storyType: type,
header: PinnedStories(
preferenceState: preferenceState,
onStoryTapped: onStoryTapped,
),
onStoryTapped: onStoryTapped,
),
const ProfileScreen(),
],
);
},
),
),
);
},
);
return ScreenTypeLayout.builder(
mobile: (BuildContext context) {
context.read<SplitViewCubit>().disableSplitView();
return MobileHomeScreen(
homeScreen: homeScreen,
);
},
tablet: (BuildContext context) => TabletHomeScreen(
homeScreen: homeScreen,
),
);
}
void onStoryTapped(Story story, {bool isPin = false}) {
final bool showWebFirst =
context.read<PreferenceCubit>().state.webFirstEnabled;
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
final bool offlineReading =
context.read<StoriesBloc>().state.offlineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
// If a story is a job story and it has a link to the job posting,
// it would be better to just navigate to the web page.
final bool isJobWithLink = story.isJob && story.url.isNotEmpty;
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} else {
final ItemScreenArgs args = ItemScreenArgs(item: story);
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
if (splitViewEnabled) {
context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else {
HackiApp.navigatorKey.currentState
?.pushNamed(
ItemScreen.routeName,
arguments: args,
)
.whenComplete(() {
context.read<ReminderCubit>().removeLastReadStoryId();
});
}
}
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) {
LinkUtil.launch(
story.url,
useReader: useReader,
offlineReading: offlineReading,
);
}
context.read<StoriesBloc>().add(
StoryRead(
story: story,
),
);
if (Platform.isIOS) {
FlutterSiriSuggestions.instance.registerActivity(
FlutterSiriActivity(
story.title,
story.id.toString(),
suggestedInvocationPhrase: '',
contentDescription: story.text,
persistentIdentifier: story.id.toString(),
),
);
}
}
void onShareExtensionTapped(String? event) {
if (event == null) return;
final int? id = event.itemId;
if (id != null) {
locator.get<StoriesRepository>().fetchItem(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
goToItemScreen(
args: ItemScreenArgs(item: item),
forceNewScreen: true,
);
}
}
});
}
}
Future<void> onSiriSuggestionTapped(String? id) async {
if (id == null) return;
final int? storyId = int.tryParse(id);
if (storyId == null) return;
await locator
.get<StoriesRepository>()
.fetchStory(id: storyId)
.then((Story? story) {
if (story == null) {
showErrorSnackBar();
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
Future<void> onNotificationTapped(String? payload) async {
if (payload == null) return;
final Map<String, dynamic> payloadJson =
jsonDecode(payload) as Map<String, dynamic>;
final int? storyId = payloadJson['storyId'] as int?;
final int? commentId = payloadJson['commentId'] as int?;
if (storyId != null && commentId != null) {
context.read<NotificationCubit>().markAsRead(commentId);
await locator
.get<StoriesRepository>()
.fetchStory(id: storyId)
.then((Story? story) {
if (story == null) {
showErrorSnackBar();
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
}
}

View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class MobileHomeScreen extends StatelessWidget {
const MobileHomeScreen({
super.key,
required this.homeScreen,
});
final Widget homeScreen;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned.fill(child: homeScreen),
if (!context.read<ReminderCubit>().state.hasShown)
const Positioned(
left: Dimens.pt24,
right: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
child: CountdownReminder(),
),
],
);
}
}

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_slidable/flutter_slidable.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 PinnedStories extends StatelessWidget {
const PinnedStories({
super.key,
required this.preferenceState,
required this.onStoryTapped,
});
final PreferenceState preferenceState;
final void Function(Story story, {bool isPin}) onStoryTapped;
@override
Widget build(BuildContext context) {
return BlocBuilder<PinCubit, PinState>(
builder: (BuildContext context, PinState state) {
return Column(
children: <Widget>[
for (final Story story in state.pinnedStories)
FadeIn(
child: Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
context.read<PinCubit>().unpinStory(story);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: preferenceState.complexStoryTileEnabled
? Icons.close
: null,
label: 'Unpin',
),
],
),
child: ColoredBox(
color: Palette.orangeAccent.withOpacity(0.2),
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
onTap: () => onStoryTapped(story, isPin: true),
showWebPreview: preferenceState.complexStoryTileEnabled,
showMetadata: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,
),
),
),
),
if (state.pinnedStories.isNotEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider(
color: Palette.orangeAccent,
),
),
],
);
},
);
}
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:responsive_builder/responsive_builder.dart';
class TabletHomeScreen extends StatelessWidget {
const TabletHomeScreen({
super.key,
required this.homeScreen,
});
final Widget homeScreen;
@override
Widget build(BuildContext context) {
return ResponsiveBuilder(
builder: (BuildContext context, SizingInformation sizeInfo) {
context.read<SplitViewCubit>().enableSplitView();
double homeScreenWidth = 428;
if (sizeInfo.screenSize.width < homeScreenWidth * 2) {
homeScreenWidth = 345;
}
return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.expanded != current.expanded,
builder: (BuildContext context, SplitViewState state) {
return Stack(
children: <Widget>[
AnimatedPositioned(
left: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
width: homeScreenWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut,
child: homeScreen,
),
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const CountdownReminder(),
),
AnimatedPositioned(
right: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut,
child: const _TabletStoryView(),
),
],
);
},
);
},
);
}
}
class _TabletStoryView extends StatelessWidget {
const _TabletStoryView();
@override
Widget build(BuildContext context) {
return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.itemScreenArgs != current.itemScreenArgs,
builder: (BuildContext context, SplitViewState state) {
if (state.itemScreenArgs != null) {
return ItemScreen.build(context, state.itemScreenArgs!);
}
return Material(
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: const Center(
child: Text('Tap on story tile to view comments.'),
),
),
);
},
);
}
}

View File

@ -0,0 +1,3 @@
export 'mobile_home_screen.dart';
export 'pinned_stories.dart';
export 'tablet_home_screen.dart';

View File

@ -1,618 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
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/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
static const String routeName = '/';
static Route<dynamic> route() {
return MaterialPageRoute<HomeScreen>(
settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => const HomeScreen(),
);
}
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware {
final Throttle featureDiscoveryDismissThrottle = Throttle(
delay: _throttleDelay,
);
late final TabController tabController;
late final StreamSubscription<String> intentDataStreamSubscription;
late final StreamSubscription<String?> notificationStreamSubscription;
late final StreamSubscription<String?> siriSuggestionStreamSubscription;
int currentIndex = 0;
static const Duration _throttleDelay = Duration(seconds: 1);
@override
void didPopNext() {
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed(
const Duration(milliseconds: 500),
locator.get<CommentCache>().resetComments,
);
}
}
@override
void initState() {
super.initState();
// This is for testing only.
// FeatureDiscovery.clearPreferences(context, <String>[
// Constants.featureLogIn,
// Constants.featureAddStoryToFavList,
// Constants.featureOpenStoryInWebView,
// Constants.featurePinToTop,
// ]);
ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped);
intentDataStreamSubscription =
ReceiveSharingIntent.getTextStream().listen(onShareExtensionTapped);
if (!selectNotificationSubject.hasListener) {
notificationStreamSubscription =
selectNotificationSubject.stream.listen(onNotificationTapped);
}
if (!siriSuggestionSubject.hasListener) {
siriSuggestionStreamSubscription =
siriSuggestionSubject.stream.listen(onSiriSuggestionTapped);
}
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (!isTesting) {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featureLogIn,
},
);
}
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
if (route == null) return;
locator
.get<RouteObserver<ModalRoute<dynamic>>>()
.subscribe(this, route);
});
tabController = TabController(vsync: this, length: 6)
..addListener(() {
setState(() {
currentIndex = tabController.index;
});
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final DeviceScreenType deviceType =
getDeviceType(MediaQuery.of(context).size);
if (context.read<StoriesBloc>().deviceScreenType != deviceType) {
context.read<StoriesBloc>().deviceScreenType = deviceType;
context.read<StoriesBloc>().add(StoriesInitialize());
}
}
@override
void dispose() {
featureDiscoveryDismissThrottle.dispose();
tabController.dispose();
intentDataStreamSubscription.cancel();
notificationStreamSubscription.cancel();
siriSuggestionStreamSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final BlocBuilder<PreferenceCubit, PreferenceState> homeScreen =
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.showComplexStoryTile != current.showComplexStoryTile ||
previous.showMetadata != current.showMetadata,
builder: (BuildContext context, PreferenceState preferenceState) {
final BlocBuilder<PinCubit, PinState> pinnedStories =
BlocBuilder<PinCubit, PinState>(
builder: (BuildContext context, PinState state) {
return Column(
children: <Widget>[
for (final Story story in state.pinnedStories)
FadeIn(
child: Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
context.read<PinCubit>().unpinStory(story);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: preferenceState.showComplexStoryTile
? Icons.close
: null,
label: 'Unpin',
),
],
),
child: Container(
color: Palette.orangeAccent.withOpacity(0.2),
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
onTap: () => onStoryTapped(story, isPin: true),
showWebPreview: preferenceState.showComplexStoryTile,
showMetadata: preferenceState.showMetadata,
showUrl: preferenceState.showUrl,
),
),
),
),
if (state.pinnedStories.isNotEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider(
color: Palette.orangeAccent,
),
),
],
);
},
);
return DefaultTabController(
length: 6,
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: PreferredSize(
preferredSize: const Size(
Dimens.zero,
Dimens.pt40,
),
child: Column(
children: <Widget>[
SizedBox(
height: MediaQuery.of(context).padding.top - Dimens.pt8,
),
Theme(
data: ThemeData(
highlightColor: Palette.transparent,
splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor,
),
child: TabBar(
isScrollable: true,
controller: tabController,
indicatorColor: Palette.orange,
indicator: CircleTabIndicator(
color: Palette.orange,
radius: Dimens.pt2,
),
indicatorPadding: const EdgeInsets.only(
bottom: Dimens.pt8,
),
onTap: (_) {
HapticFeedback.selectionClick();
},
tabs: <Widget>[
for (int i = 0; i < StoriesBloc.types.length; i++)
Tab(
key: ValueKey<StoryType>(
StoriesBloc.types.elementAt(i),
),
child: Text(
StoriesBloc.types.elementAt(i).label,
style: TextStyle(
fontSize: currentIndex == i
? TextDimens.pt14
: TextDimens.pt10,
color: currentIndex == i
? Palette.orange
: Palette.grey,
),
),
),
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,
tapTarget: const Icon(
Icons.person,
size: TextDimens.pt16,
color: Palette.white,
),
featureId: Constants.featureLogIn,
title: const Text('Log in for more'),
description: const Text(
'Log in using your Hacker News account '
'to check out stories and comments you have '
'posted in the past, and get in-app '
'notification when there is new reply to '
'your comments or stories.',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: BlocBuilder<NotificationCubit,
NotificationState>(
buildWhen: (
NotificationState previous,
NotificationState current,
) =>
previous.unreadCommentsIds.length !=
current.unreadCommentsIds.length,
builder: (
BuildContext context,
NotificationState state,
) {
return Badge(
showBadge: state.unreadCommentsIds.isNotEmpty,
borderRadius: BorderRadius.circular(100),
badgeContent: Container(
height: Dimens.pt3,
width: Dimens.pt3,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Palette.white,
),
),
child: Icon(
Icons.person,
size: currentIndex == 5
? TextDimens.pt16
: TextDimens.pt12,
color: currentIndex == 5
? Palette.orange
: Palette.grey,
),
);
},
),
),
),
],
),
),
],
),
),
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: tabController,
children: <Widget>[
for (final StoryType type in StoriesBloc.types)
StoriesListView(
key: ValueKey<StoryType>(type),
storyType: type,
header: pinnedStories,
onStoryTapped: onStoryTapped,
),
const ProfileScreen(),
],
),
),
);
},
);
return ScreenTypeLayout.builder(
mobile: (BuildContext context) {
context.read<SplitViewCubit>().disableSplitView();
return _MobileHomeScreen(
homeScreen: homeScreen,
);
},
tablet: (BuildContext context) => _TabletHomeScreen(
homeScreen: homeScreen,
),
);
}
Future<bool> onFeatureDiscoveryDismissed() {
featureDiscoveryDismissThrottle.run(() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).clearSnackBars();
showSnackBar(content: 'Tap on icon to continue');
});
return Future<bool>.value(false);
}
void onStoryTapped(Story story, {bool isPin = false}) {
final bool showWebFirst =
context.read<PreferenceCubit>().state.showWebFirst;
final bool useReader = context.read<PreferenceCubit>().state.useReader;
final bool offlineReading =
context.read<StoriesBloc>().state.offlineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
// If a story is a job story and it has a link to the job posting,
// it would be better to just navigate to the web page.
final bool isJobWithLink = story.isJob && story.url.isNotEmpty;
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} else {
final ItemScreenArgs args = ItemScreenArgs(item: story);
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
if (splitViewEnabled) {
context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else {
HackiApp.navigatorKey.currentState
?.pushNamed(
ItemScreen.routeName,
arguments: args,
)
.whenComplete(() {
context.read<ReminderCubit>().removeLastReadStoryId();
});
}
}
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) {
LinkUtil.launch(
story.url,
useReader: useReader,
offlineReading: offlineReading,
);
}
context.read<StoriesBloc>().add(
StoryRead(
story: story,
),
);
if (Platform.isIOS) {
FlutterSiriSuggestions.instance.registerActivity(
FlutterSiriActivity(
story.title,
story.id.toString(),
suggestedInvocationPhrase: '',
contentDescription: story.text,
persistentIdentifier: story.id.toString(),
),
);
}
}
void showOnboarding() {
Navigator.push<dynamic>(
context,
MaterialPageRoute<dynamic>(
builder: (BuildContext context) => const OnboardingView(),
fullscreenDialog: true,
),
);
}
void onShareExtensionTapped(String? event) {
if (event == null) return;
final int? id = event.itemId;
if (id != null) {
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
goToItemScreen(
args: ItemScreenArgs(item: item),
forceNewScreen: true,
);
}
}
});
}
}
Future<void> onSiriSuggestionTapped(String? id) async {
if (id == null) return;
final int? storyId = int.tryParse(id);
if (storyId == null) return;
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.then((Story? story) {
if (story == null) {
showSnackBar(content: 'Something went wrong...');
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
Future<void> onNotificationTapped(String? payload) async {
if (payload == null) return;
final Map<String, dynamic> payloadJson =
jsonDecode(payload) as Map<String, dynamic>;
final int? storyId = payloadJson['storyId'] as int?;
final int? commentId = payloadJson['commentId'] as int?;
if (storyId != null && commentId != null) {
context.read<NotificationCubit>().markAsRead(commentId);
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.then((Story? story) {
if (story == null) {
showSnackBar(content: 'Something went wrong...');
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
}
}
class _MobileHomeScreen extends StatelessWidget {
const _MobileHomeScreen({
Key? key,
required this.homeScreen,
}) : super(key: key);
final Widget homeScreen;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned.fill(child: homeScreen),
if (!context.read<ReminderCubit>().state.hasShown)
const Positioned(
left: Dimens.pt24,
right: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
child: CountdownReminder(),
),
],
);
}
}
class _TabletHomeScreen extends StatelessWidget {
const _TabletHomeScreen({
Key? key,
required this.homeScreen,
}) : super(key: key);
final Widget homeScreen;
@override
Widget build(BuildContext context) {
return ResponsiveBuilder(
builder: (BuildContext context, SizingInformation sizeInfo) {
context.read<SplitViewCubit>().enableSplitView();
double homeScreenWidth = 428;
if (sizeInfo.screenSize.width < homeScreenWidth * 2) {
homeScreenWidth = 345;
}
return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.expanded != current.expanded,
builder: (BuildContext context, SplitViewState state) {
return Stack(
children: <Widget>[
AnimatedPositioned(
left: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
width: state.expanded ? Dimens.zero : homeScreenWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut,
child: homeScreen,
),
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const CountdownReminder(),
),
AnimatedPositioned(
right: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut,
child: const _TabletStoryView(),
),
],
);
},
);
},
);
}
}
class _TabletStoryView extends StatelessWidget {
const _TabletStoryView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.itemScreenArgs != current.itemScreenArgs,
builder: (BuildContext context, SplitViewState state) {
if (state.itemScreenArgs != null) {
return ItemScreen.build(context, state.itemScreenArgs!);
}
return Material(
child: Container(
color: Theme.of(context).canvasColor,
child: const Center(
child: Text('Tap on story tile to view comments.'),
),
),
);
},
);
}
}

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,17 +9,14 @@ 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/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:responsive_builder/responsive_builder.dart';
import 'package:share_plus/share_plus.dart';
class ItemScreenArgs extends Equatable {
const ItemScreenArgs({
@ -36,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;
@ -152,7 +147,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
initialRefreshStatus: RefreshStatus.refreshing,
);
final FocusNode focusNode = FocusNode();
final String happyFace = Constants.happyFaces.pickRandomly()!;
final Throttle storyLinkTapThrottle = Throttle(
delay: _storyLinkTapThrottleDelay,
);
@ -179,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);
@ -236,20 +228,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<EditCubit>().state.replyingTo == null
? 'updated'
: 'submitted';
final String msg =
'Comment $verb! ${Constants.happyFaces.pickRandomly()}';
final String msg = 'Comment $verb! ${Constants.happyFace}';
focusNode.unfocus();
HapticFeedback.lightImpact();
showSnackBar(content: msg);
context.read<EditCubit>().onReplySubmittedSuccessfully();
context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) {
showSnackBar(
content: 'Something went wrong...'
'${Constants.sadFaces.pickRandomly()}',
label: 'Okay',
action: ScaffoldMessenger.of(context).hideCurrentSnackBar,
);
showErrorSnackBar();
context.read<PostCubit>().reset();
}
},
@ -328,8 +314,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:
@ -369,8 +353,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,
),
@ -406,15 +388,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 =
@ -530,154 +503,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
);
}
Future<void> onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
storyLinkTapThrottle.run(() {
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
}
});
});
} else {
LinkUtil.launch(link);
}
}
void onMoreTapped(Item item, Rect? rect) {
HapticFeedback.lightImpact();
if (item.dead || item.deleted) {
return;
}
final bool isBlocked =
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
showModalBottomSheet<MenuAction>(
context: context,
builder: (BuildContext context) {
return MorePopupMenu(
item: item,
isBlocked: isBlocked,
showSnackBar: showSnackBar,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
);
},
).then((MenuAction? action) {
if (action != null) {
switch (action) {
case MenuAction.upvote:
break;
case MenuAction.downvote:
break;
case MenuAction.share:
onShareTapped(item, rect);
break;
case MenuAction.flag:
onFlagTapped(item);
break;
case MenuAction.block:
onBlockTapped(item, isBlocked: isBlocked);
break;
case MenuAction.cancel:
break;
}
}
});
}
void onShareTapped(Item item, Rect? rect) {
Share.share(
'https://news.ycombinator.com/item?id=${item.id}',
sharePositionOrigin: rect,
);
}
void onFlagTapped(Item item) {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Flag this comment?'),
content: Text(
'Flag this comment posted by ${item.by}?',
style: const TextStyle(
color: Palette.grey,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Yes',
),
),
],
);
},
).then((bool? yesTapped) {
if (yesTapped ?? false) {
context.read<AuthBloc>().add(AuthFlag(item: item));
showSnackBar(content: 'Comment flagged!');
}
});
}
void onBlockTapped(Item item, {required bool isBlocked}) {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('${isBlocked ? 'Unblock' : 'Block'} this user?'),
content: Text(
'Do you want to ${isBlocked ? 'unblock' : 'block'} ${item.by}'
' and ${isBlocked ? 'display' : 'hide'} '
'comments posted by this user?',
style: const TextStyle(
color: Palette.grey,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Yes',
),
),
],
);
},
).then((bool? yesTapped) {
if (yesTapped ?? false) {
if (isBlocked) {
context.read<BlocklistCubit>().removeFromBlocklist(item.by);
} else {
context.read<BlocklistCubit>().addToBlocklist(item.by);
}
showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
}
});
}
void onSendTapped() {
final AuthBloc authBloc = context.read<AuthBloc>();
final PostCubit postCubit = context.read<PostCubit>();
@ -700,20 +525,4 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
onLoginTapped();
}
}
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,
);
},
);
}
}

View File

@ -7,20 +7,16 @@ import 'package:hacki/styles/styles.dart';
class CustomAppBar extends AppBar {
CustomAppBar({
Key? key,
super.key,
required ScrollController scrollController,
required Item item,
required Color backgroundColor,
required Future<bool> Function() onBackgroundTap,
required Future<bool> Function() onDismiss,
required Color super.backgroundColor,
required VoidCallback onFontSizeTap,
required GlobalKey fontSizeIconButtonKey,
bool splitViewEnabled = false,
VoidCallback? onZoomTap,
bool? expanded,
}) : super(
key: key,
backgroundColor: backgroundColor,
elevation: Dimens.zero,
actions: <Widget>[
if (splitViewEnabled) ...<Widget>[
@ -43,26 +39,26 @@ class CustomAppBar extends AppBar {
),
IconButton(
key: fontSizeIconButtonKey,
icon: const Icon(
Icons.format_size,
icon: Text(
String.fromCharCode(FeatherIcons.type.codePoint),
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
),
),
onPressed: onFontSizeTap,
),
if (item is Story)
PinIconButton(
story: item,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
),
FavIconButton(
storyId: item.id,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
),
LinkIconButton(
storyId: item.id,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
),
],
);

View File

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

View File

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

View File

@ -2,22 +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({
Key? key,
super.key,
required this.usernameController,
required this.passwordController,
required this.showSnackBar,
}) : super(key: key);
});
final TextEditingController usernameController;
final TextEditingController passwordController;
final Function({
final void Function({
required String content,
VoidCallback? action,
String? label,
@ -28,9 +27,10 @@ class LoginDialog extends StatelessWidget {
return BlocConsumer<AuthBloc, AuthState>(
listener: (BuildContext context, AuthState state) {
if (state.isLoggedIn) {
final String happyFace = Constants.happyFaces.pickRandomly()!;
Navigator.pop(context);
showSnackBar(content: 'Logged in successfully! $happyFace');
showSnackBar(
content: 'Logged in successfully! ${Constants.happyFace}',
);
}
},
builder: (BuildContext context, AuthState state) {
@ -87,13 +87,13 @@ 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,
),
child: Text(
'Something went wrong...',
style: TextStyle(
Constants.errorMessage,
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';
@ -17,7 +16,7 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class MainView extends StatelessWidget {
const MainView({
Key? key,
super.key,
required this.scrollController,
required this.refreshController,
required this.commentEditingController,
@ -29,7 +28,7 @@ class MainView extends StatelessWidget {
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
}) : super(key: key);
});
final ScrollController scrollController;
final RefreshController refreshController;
@ -38,11 +37,14 @@ class MainView extends StatelessWidget {
final FocusNode focusNode;
final double topPadding;
final bool splitViewEnabled;
final Function(Item item, Rect? rect) onMoreTapped;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
static const int _loadingIndicatorOpacityAnimationDuration = 300;
static const double _trailingBoxHeight = 240;
@override
Widget build(BuildContext context) {
return Stack(
@ -130,9 +132,9 @@ class MainView extends StatelessWidget {
state.comments.isNotEmpty) ||
state.onlyShowTargetComment) {
return SizedBox(
height: 240,
height: _trailingBoxHeight,
child: Center(
child: Text(Constants.happyFaces.pickRandomly()!),
child: Text(Constants.happyFace),
),
);
} else {
@ -194,8 +196,13 @@ class MainView extends StatelessWidget {
buildWhen: (CommentsState prev, CommentsState current) =>
prev.status != current.status,
builder: (BuildContext context, CommentsState state) {
return Visibility(
visible: state.status == CommentsStatus.loading,
return AnimatedOpacity(
opacity: state.status == CommentsStatus.loading
? NumSwitch.on
: NumSwitch.off,
duration: const Duration(
milliseconds: _loadingIndicatorOpacityAnimationDuration,
),
child: const LinearProgressIndicator(),
);
},
@ -208,7 +215,6 @@ class MainView extends StatelessWidget {
class _ParentItemSection extends StatelessWidget {
const _ParentItemSection({
Key? key,
required this.scrollController,
required this.refreshController,
required this.commentEditingController,
@ -221,7 +227,7 @@ class _ParentItemSection extends StatelessWidget {
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
}) : super(key: key);
});
final ScrollController scrollController;
final RefreshController refreshController;
@ -231,7 +237,7 @@ class _ParentItemSection extends StatelessWidget {
final FocusNode focusNode;
final double topPadding;
final bool splitViewEnabled;
final Function(Item item, Rect? rect) onMoreTapped;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
@ -317,8 +323,10 @@ class _ParentItemSection extends StatelessWidget {
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader:
context.read<PreferenceCubit>().state.useReader,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
@ -331,18 +339,14 @@ 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
.bodyText1
.bodyLarge
?.color,
),
children: <TextSpan>[
@ -350,10 +354,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,
@ -365,15 +366,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,
),
),
)
@ -382,36 +385,39 @@ 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: SelectableLinkify(
text: state.item.text,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
style: TextStyle(
fontSize: context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
),
linkStyle: TextStyle(
fontSize: context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
),
],

View File

@ -1,32 +1,26 @@
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/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class MorePopupMenu extends StatelessWidget {
const MorePopupMenu({
Key? key,
super.key,
required this.item,
required this.isBlocked,
required this.showSnackBar,
required this.onStoryLinkTapped,
required this.onLoginTapped,
}) : super(key: key);
});
final Item item;
final bool isBlocked;
final Function({
required String content,
VoidCallback? action,
String? label,
}) showSnackBar;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
@ -43,24 +37,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(

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,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart';
@ -61,36 +62,29 @@ class PollView extends StatelessWidget {
listener: (BuildContext context, VoteState voteState) {
ScaffoldMessenger.of(context).clearSnackBars();
if (voteState.status == VoteStatus.submitted) {
showSnackBar(
context,
context.showSnackBar(
content: 'Vote submitted successfully.',
);
} else if (voteState.status == VoteStatus.canceled) {
showSnackBar(context, content: 'Vote canceled.');
context.showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) {
showSnackBar(
context,
content: 'Something went wrong...',
);
context.showErrorSnackBar();
} else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) {
showSnackBar(
context,
context.showSnackBar(
content: "You can't downvote because"
' you are karmaly broke.',
);
} else if (voteState.status ==
VoteStatus.failureNotLoggedIn) {
showSnackBar(
context,
context.showSnackBar(
content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped,
label: 'Log in',
);
} else if (voteState.status ==
VoteStatus.failureBeHumble) {
showSnackBar(
context,
context.showSnackBar(
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.bodyText1?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
);
}
}

View File

@ -3,11 +3,11 @@ 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/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/link_util.dart';
@ -111,7 +111,8 @@ class _ReplyBoxState extends State<ReplyBox> {
...<Widget>[
if (replyingTo != null)
AnimatedOpacity(
opacity: expanded ? 1 : 0,
opacity:
expanded ? NumSwitch.on : NumSwitch.off,
duration: const Duration(milliseconds: 300),
child: IconButton(
key: const Key('quote'),
@ -335,9 +336,10 @@ class _ReplyBoxState extends State<ReplyBox> {
child: SingleChildScrollView(
child: SelectableLinkify(
scrollPhysics: const NeverScrollableScrollPhysics(),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context).textScaleFactor *
TextDimens.pt15,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
linkStyle: const TextStyle(
fontSize: TextDimens.pt15,
color: Palette.orange,
),
onOpen: (LinkableElement link) =>

View File

@ -9,19 +9,19 @@ import 'package:responsive_builder/responsive_builder.dart';
class TimeMachineDialog extends StatelessWidget {
const TimeMachineDialog({
Key? key,
super.key,
required this.comment,
required this.size,
required this.deviceType,
required this.widthFactor,
required this.onStoryLinkTapped,
}) : super(key: key);
});
final Comment comment;
final Size size;
final DeviceScreenType deviceType;
final double widthFactor;
final Function(String) onStoryLinkTapped;
final void Function(String) onStoryLinkTapped;
@override
Widget build(BuildContext context) {

View File

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

View File

@ -0,0 +1,7 @@
enum PageType {
fav,
history,
settings,
search,
notification,
}

File diff suppressed because it is too large Load Diff

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';
@ -22,7 +21,7 @@ class InboxView extends StatelessWidget {
final RefreshController refreshController;
final List<Comment> comments;
final List<int> unreadCommentsIds;
final Function(Comment) onCommentTapped;
final void Function(Comment) onCommentTapped;
final VoidCallback onMarkAllAsReadTapped;
final VoidCallback onLoadMore;
final VoidCallback onRefresh;

View File

@ -58,42 +58,67 @@ class OfflineListTile extends StatelessWidget {
isThreeLine: true,
onTap: () {
if (state.downloadStatus == StoriesDownloadStatus.downloading) {
return;
showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Abort downloading?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
).then((bool? abortDownloading) {
if (abortDownloading ?? false) {
Wakelock.enable();
context.read<StoriesBloc>().add(StoriesCancelDownload());
}
});
} else {
Connectivity().checkConnectivity().then((ConnectivityResult res) {
if (res != ConnectivityResult.none) {
showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Download web pages as well?'),
content: const Text('It will take longer time.'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
).then((bool? includeWebPage) {
if (includeWebPage != null) {
Wakelock.enable();
context.read<StoriesBloc>().add(
StoriesDownload(
includingWebPage: includeWebPage,
),
);
}
});
}
});
}
Connectivity().checkConnectivity().then((ConnectivityResult res) {
if (res != ConnectivityResult.none) {
showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Download web pages as well?'),
content: const Text('It will take longer time.'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
).then((bool? includeWebPage) {
if (includeWebPage != null) {
Wakelock.enable();
context.read<StoriesBloc>().add(
StoriesDownload(
includingWebPage: includeWebPage,
),
);
}
});
}
});
},
);
},

View File

@ -0,0 +1,617 @@
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_email_sender/flutter_email_sender.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/profile/models/page_type.dart';
import 'package:hacki/screens/profile/widgets/offline_list_tile.dart';
import 'package:hacki/screens/profile/widgets/tab_bar_settings.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
class Settings extends StatefulWidget {
const Settings({
super.key,
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();
}
class _SettingsState extends State<Settings> {
@override
Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
builder: (BuildContext context, PreferenceState preferenceState) {
return Positioned.fill(
top: Dimens.pt50,
child: Visibility(
visible: widget.pageType == PageType.settings,
child: SingleChildScrollView(
child: Column(
children: <Widget>[
ListTile(
title: Text(
widget.authState.isLoggedIn ? 'Log Out' : 'Log In',
),
subtitle: Text(
widget.authState.isLoggedIn
? widget.authState.username
: widget.magicWord,
),
onTap: () {
if (widget.authState.isLoggedIn) {
onLogoutTapped();
} else {
widget.onLoginTapped();
}
},
),
const OfflineListTile(),
const SizedBox(
height: Dimens.pt8,
),
Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Row(
children: const <Widget>[
SizedBox(
width: Dimens.pt16,
),
Text('Default fetch mode'),
Spacer(),
],
),
),
Flexible(
child: Row(
children: const <Widget>[
Text('Default comments order'),
Spacer(),
],
),
),
],
),
Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt16,
),
DropdownButton<FetchMode>(
value: preferenceState.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) =>
DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt16,
),
),
),
)
.toList(),
onChanged: (FetchMode? fetchMode) {
if (fetchMode != null) {
HapticFeedback.selectionClick();
context.read<PreferenceCubit>().update(
FetchModePreference(),
to: fetchMode.index,
);
}
},
),
const Spacer(),
],
),
),
Flexible(
child: Row(
children: <Widget>[
DropdownButton<CommentsOrder>(
value: preferenceState.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) =>
DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt16,
),
),
),
)
.toList(),
onChanged: (CommentsOrder? order) {
if (order != null) {
HapticFeedback.selectionClick();
context.read<PreferenceCubit>().update(
CommentsOrderPreference(),
to: order.index,
);
}
},
),
const Spacer(),
],
),
),
],
),
const TabBarSettings(),
const Divider(),
StoryTile(
showWebPreview: preferenceState.complexStoryTileEnabled,
showMetadata: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,
story: Story.placeholder(),
onTap: () => LinkUtil.launch(Constants.guidelineLink),
),
const Divider(),
for (final Preference<dynamic> preference in preferenceState
.preferences
.whereType<BooleanPreference>()
.where(
(Preference<dynamic> e) => e.isDisplayable,
))
SwitchListTile(
title: Text(preference.title),
subtitle: preference.subtitle.isNotEmpty
? Text(preference.subtitle)
: null,
value: preferenceState.isOn(
preference as BooleanPreference,
),
onChanged: (bool val) {
HapticFeedback.lightImpact();
context
.read<PreferenceCubit>()
.update(preference, to: val);
if (preference is MarkReadStoriesModePreference &&
val == false) {
context
.read<StoriesBloc>()
.add(ClearAllReadStories());
}
},
activeColor: Palette.orange,
),
ListTile(
title: const Text(
'Font',
),
onTap: showFontSettingDialog,
),
ListTile(
title: const Text(
'Theme',
),
onTap: showThemeSettingDialog,
),
ListTile(
title: const Text(
'Clear Data',
),
onTap: showClearDataDialog,
),
ListTile(
title: const Text('About'),
subtitle: const Text('nothing interesting here.'),
onTap: showAboutHackiDialog,
),
const SizedBox(
height: Dimens.pt48,
),
],
),
),
),
);
},
);
}
void onLogoutTapped() {
final AuthBloc authBloc = context.read<AuthBloc>();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
content: Text(
'Log out as ${authBloc.state.username}?',
style: const TextStyle(
fontSize: TextDimens.pt16,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
context.read<AuthBloc>().add(AuthLogout());
context.read<HistoryCubit>().reset();
},
child: const Text(
'Log out',
),
),
],
);
},
);
}
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,
builder: (_) {
final AdaptiveThemeMode themeMode = AdaptiveTheme.of(context).mode;
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.light,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setLight(),
title: const Text('Light'),
),
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.dark,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setDark(),
title: const Text('Dark'),
),
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.system,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setSystem(),
title: const Text('System'),
),
],
),
);
},
);
}
void showClearDataDialog() {
showDialog<void>(
context: context,
builder: (_) {
return AlertDialog(
title: const Text('Clear Data?'),
content: const Text(
'Clear all cached images, stories and comments.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
style: TextStyle(
color: Palette.orange,
),
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
locator
.get<SembastRepository>()
.deleteAllCachedComments()
.whenComplete(
locator.get<OfflineRepository>().deleteAll,
)
.whenComplete(
locator.get<PreferenceRepository>().clearAllReadStories,
)
.whenComplete(
DefaultCacheManager().emptyCache,
)
.whenComplete(() {
showSnackBar(content: 'Data cleared!');
});
},
child: const Text(
'Yes',
),
),
],
);
},
);
}
Future<void> showAboutHackiDialog() async {
final PackageInfo packageInfo = await PackageInfo.fromPlatform();
final String version = packageInfo.version;
if (mounted) {
showAboutDialog(
context: context,
applicationName: 'Hacki',
applicationVersion: 'v$version',
applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(
Dimens.pt12,
),
),
child: Image.asset(
Constants.hackiIconPath,
height: Dimens.pt50,
width: Dimens.pt50,
),
),
children: <Widget>[
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.portfolioLink,
),
child: Row(
children: const <Widget>[
Icon(
FontAwesomeIcons.addressCard,
),
SizedBox(
width: Dimens.pt12,
),
Text('Developer'),
],
),
),
ElevatedButton(
onPressed: onReportIssueTapped,
child: Row(
children: const <Widget>[
Icon(
Icons.bug_report_outlined,
),
SizedBox(
width: Dimens.pt12,
),
Text('Report issue'),
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.githubLink,
),
child: Row(
children: const <Widget>[
Icon(
FontAwesomeIcons.github,
),
SizedBox(
width: Dimens.pt12,
),
Text('Source code'),
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Platform.isIOS
? Constants.appStoreLink
: Constants.googlePlayLink,
),
child: Row(
children: const <Widget>[
Icon(
Icons.thumb_up,
),
SizedBox(
width: Dimens.pt12,
),
Text('Like this app?'),
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.sponsorLink,
),
child: Row(
children: const <Widget>[
Icon(
FeatherIcons.coffee,
),
SizedBox(
width: Dimens.pt12,
),
Text('Buy me a coffee'),
],
),
),
],
);
}
}
Future<void> onReportIssueTapped() async {
await showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
actions: <Widget>[
ElevatedButton(
onPressed: onSendEmailTapped,
child: Row(
children: const <Widget>[
Icon(
Icons.email,
),
SizedBox(
width: Dimens.pt12,
),
Text('Email'),
],
),
),
ElevatedButton(
onPressed: () => onGithubTapped(context.rect),
child: Row(
children: const <Widget>[
Icon(
Icons.bug_report_outlined,
),
SizedBox(
width: Dimens.pt12,
),
Text('GitHub'),
],
),
),
],
);
},
);
}
/// Send an email with log attached.
Future<void> onSendEmailTapped() async {
final Directory tempDir = await getTemporaryDirectory();
final String previousLogPath =
'${tempDir.path}/${Constants.previousLogFileName}';
await LogUtil.exportLog();
final Email email = Email(
body:
'''Please describe how to reproduce the bug or what you have down before the bug occurred:''',
subject: 'Found a bug in Hacki',
recipients: <String>[Constants.supportEmail],
attachmentPaths: <String>[previousLogPath],
);
await FlutterEmailSender.send(email);
}
/// Open an issue on GitHub.
Future<void> onGithubTapped(Rect? rect) async {
try {
final File originalFile = await LogUtil.exportLog();
final XFile file = XFile(originalFile.path);
final ShareResult result = await Share.shareXFiles(
<XFile>[file],
subject: 'hacki_log',
sharePositionOrigin: rect,
);
if (result.status == ShareResultStatus.success) {
LinkUtil.launchInExternalBrowser(Constants.githubIssueLink);
}
} catch (error, stackTrace) {
locator.get<Logger>().e(
'Error caught in onGithubTapped',
error,
stackTrace,
);
}
}
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart';
class TabBarSettings extends StatefulWidget {
const TabBarSettings({super.key});
@override
State<TabBarSettings> createState() => _TabBarSettingsState();
}
class _TabBarSettingsState extends State<TabBarSettings> {
static const double height = 60;
static const double width = 300;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Row(
children: const <Widget>[
SizedBox(
width: Dimens.pt16,
),
Text('Default tab bar'),
Spacer(),
],
),
BlocBuilder<TabCubit, TabState>(
builder: (BuildContext context, TabState state) {
return Center(
child: SizedBox(
height: height,
width: width,
child: ReorderableListView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
onReorder: context.read<TabCubit>().update,
onReorderStart: (_) => HapticFeedback.lightImpact(),
children: <Widget>[
for (final StoryType tab in state.tabs)
InkWell(
key: ValueKey<StoryType>(tab),
child: SizedBox(
width: 60,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(tab.label),
const Icon(
Icons.drag_handle_outlined,
color: Palette.grey,
size: TextDimens.pt14,
),
],
),
),
),
],
),
),
);
},
),
],
);
}
}

View File

@ -1,3 +1,5 @@
export 'centered_message_view.dart';
export 'inbox_view.dart';
export 'offline_list_tile.dart';
export 'settings.dart';
export 'tab_bar_settings.dart';

View File

@ -1,4 +1,4 @@
export 'home_screen.dart';
export 'home/home_screen.dart';
export 'item/item_screen.dart';
export 'profile/profile_screen.dart';
export 'search/search_screen.dart';

View File

@ -164,23 +164,22 @@ class _SearchScreenState extends State<SearchScreen> {
FadeIn(
child: StoryTile(
showWebPreview:
prefState.showComplexStoryTile,
showMetadata: prefState.showMetadata,
showUrl: prefState.showUrl,
prefState.complexStoryTileEnabled,
showMetadata: prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
story: e,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
),
),
),
if (!prefState.showComplexStoryTile)
if (!prefState.complexStoryTileEnabled)
const Divider(
height: Dimens.zero,
),
],
)
.expand((List<Widget> e) => e)
.toList(),
.expand((List<Widget> e) => e),
const SizedBox(
height: Dimens.pt40,
),

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hacki/screens/widgets/widgets.dart';
typedef DateRangeCallback = Function(DateTime, DateTime);
typedef DateRangeCallback = void Function(DateTime, DateTime);
enum CustomDateTimeRange {
pastDay(Duration(days: 1), label: 'past day'),
@ -17,10 +17,10 @@ enum CustomDateTimeRange {
class CustomRangeFilterChip extends StatelessWidget {
const CustomRangeFilterChip({
Key? key,
super.key,
required this.range,
required this.onTap,
}) : super(key: key);
});
final CustomDateTimeRange range;
final DateRangeCallback onTap;

View File

@ -5,14 +5,14 @@ import 'package:intl/intl.dart';
class DateTimeRangeFilterChip extends StatelessWidget {
const DateTimeRangeFilterChip({
Key? key,
super.key,
required this.filter,
required this.onDateTimeRangeUpdated,
required this.onDateTimeRangeRemoved,
}) : super(key: key);
});
final DateTimeRangeFilter? filter;
final Function(DateTime, DateTime) onDateTimeRangeUpdated;
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
final VoidCallback onDateTimeRangeRemoved;
static final DateFormat _dateTimeFormatter = DateFormat.yMMMd();

View File

@ -4,9 +4,9 @@ import 'package:hacki/screens/widgets/widgets.dart';
class PostedByFilterChip extends StatelessWidget {
const PostedByFilterChip({
Key? key,
super.key,
required this.filter,
}) : super(key: key);
});
final PostedByFilter? filter;

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

@ -17,6 +17,25 @@ class WebViewScreen extends StatefulWidget {
}
class _WebViewScreenState extends State<WebViewScreen> {
final WebViewController controller = WebViewController();
@override
void initState() {
super.initState();
getUrlAndLoadWebView();
}
Future<void> getUrlAndLoadWebView() async {
final String? html = await locator.get<OfflineRepository>().getHtml(
url: widget.url,
);
if (html != null) {
await controller.loadHtmlString(html);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -32,16 +51,8 @@ class _WebViewScreenState extends State<WebViewScreen> {
),
centerTitle: true,
),
body: WebView(
onWebViewCreated: (WebViewController controller) async {
final String? html = await locator.get<OfflineRepository>().getHtml(
url: widget.url,
);
if (html != null) {
await controller.loadHtmlString(html);
}
},
body: WebViewWidget(
controller: controller,
),
);
}

View File

@ -17,7 +17,7 @@ class BlocBuilder3<
BlocC extends StateStreamable<BlocCState>,
BlocCState> extends StatelessWidget {
const BlocBuilder3({
Key? key,
super.key,
required this.builder,
this.blocA,
this.blocB,
@ -25,7 +25,7 @@ class BlocBuilder3<
this.buildWhenA,
this.buildWhenB,
this.buildWhenC,
}) : super(key: key);
});
final BlocWidgetBuilder3<BlocAState, BlocBState, BlocCState> builder;

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,13 +1,12 @@
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';
@ -33,13 +32,15 @@ class CommentTile extends StatelessWidget {
final Comment comment;
final int level;
final bool actionable;
final Function(Comment)? onReplyTapped;
final Function(Comment, Rect?)? onMoreTapped;
final Function(Comment)? onEditTapped;
final Function(Comment)? onRightMoreTapped;
final Function(String) onStoryLinkTapped;
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;
static final Map<int, Color> _colors = <int, Color>{};
@override
Widget build(BuildContext context) {
return BlocProvider<CollapseCubit>(
@ -135,8 +136,9 @@ class CommentTile extends StatelessWidget {
Text(
comment.by,
style: TextStyle(
color:
prefState.showEyeCandy ? orange : color,
color: prefState.eyeCandyEnabled
? orange
: color,
),
),
if (comment.by == opUsername)
@ -156,136 +158,45 @@ 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 {
LinkUtil.launch(link.url);
}
},
),
onTap: () => onTextTapped(context),
)
: SelectableLinkify(
key: ValueKey<int>(comment.id),
text: comment.text,
style: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
onTap: () => onTextTapped(context),
)
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: _CommentText(
key: ValueKey<int>(comment.id),
comment: comment,
onLinkTapped: _onLinkTapped,
),
),
),
],
),
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(
@ -328,7 +239,7 @@ class CommentTile extends StatelessWidget {
final double commentBackgroundColorOpacity =
Theme.of(context).brightness == Brightness.dark ? 0.03 : 0.15;
final Color commentColor = prefState.showEyeCandy
final Color commentColor = prefState.eyeCandyEnabled
? color.withOpacity(commentBackgroundColorOpacity)
: Palette.transparent;
final bool isMyComment = myUsername == comment.by;
@ -375,8 +286,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]!;
@ -405,8 +314,79 @@ class CommentTile extends StatelessWidget {
return color;
}
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 _onLinkTapped(LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
}
}
class _CommentText extends StatelessWidget {
const _CommentText({
super.key,
required this.comment,
required this.onLinkTapped,
});
final Comment comment;
final void Function(LinkableElement) onLinkTapped;
@override
Widget build(BuildContext context) {
final PreferenceState prefState = context.read<PreferenceCubit>().state;
final TextStyle style = TextStyle(
fontSize: prefState.fontSize.fontSize,
);
final TextStyle linkStyle = TextStyle(
fontSize: prefState.fontSize.fontSize,
decoration: TextDecoration.underline,
color: Palette.orange,
);
if (comment is BuildableComment) {
return SelectableText.rich(
buildTextSpan(
(comment as BuildableComment).elements,
style: style,
linkStyle: linkStyle,
onOpen: onLinkTapped,
),
onTap: () => onTextTapped(context),
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
comment: comment as BuildableComment,
),
);
} else {
return SelectableLinkify(
text: comment.text,
style: style,
linkStyle: linkStyle,
onOpen: onLinkTapped,
onTap: () => onTextTapped(context),
);
}
}
void onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapse) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
}

View File

@ -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);

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