Compare commits

..

23 Commits

Author SHA1 Message Date
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
64 changed files with 944 additions and 1179 deletions

View File

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

View File

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

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

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

View File

@ -49,7 +49,7 @@ latest_testflight_build_number
# Prep the xcodeproject from Flutter without building (`--config-only`) # Prep the xcodeproject from Flutter without building (`--config-only`)
sh( sh(
"flutter", "build", "ios", "--config-only", "/Users/runner/work/Hacki/Hacki/submodules/flutter/bin/flutter", "build", "ios", "--config-only",
"--release", "--no-pub", "--no-codesign", "--release", "--no-pub", "--no-codesign",
"--build-number", new_build_number.to_s "--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 { await _authRepository.loggedIn.then((bool loggedIn) async {
if (loggedIn) { if (loggedIn) {
final String? username = await _authRepository.username; final String? username = await _authRepository.username;
final User user = final User user = await _storiesRepository.fetchUser(id: username!);
await _storiesRepository.fetchUserBy(userId: username!);
emit( emit(
state.copyWith( state.copyWith(
@ -84,8 +83,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
if (successful) { if (successful) {
final User user = final User user = await _storiesRepository.fetchUser(id: event.username);
await _storiesRepository.fetchUserBy(userId: event.username);
emit( emit(
state.copyWith( state.copyWith(
user: user, user: user,

View File

@ -56,15 +56,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
static const int _tabletSmallPageSize = 15; static const int _tabletSmallPageSize = 15;
static const int _tabletLargePageSize = 25; 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( Future<void> onInitialize(
StoriesInitialize event, StoriesInitialize event,
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
@ -72,7 +63,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
_streamSubscription ??= _streamSubscription ??=
_preferenceCubit.stream.listen((PreferenceState event) { _preferenceCubit.stream.listen((PreferenceState event) {
final bool isComplexTile = event.complexStoryTileEnabled; final bool isComplexTile = event.complexStoryTileEnabled;
final int pageSize = _getPageSize(isComplexTile: isComplexTile); final int pageSize = getPageSize(isComplexTile: isComplexTile);
if (pageSize != state.currentPageSize) { if (pageSize != state.currentPageSize) {
add(StoriesPageSizeChanged(pageSize: pageSize)); add(StoriesPageSizeChanged(pageSize: pageSize));
@ -80,7 +71,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}); });
final bool hasCachedStories = await _offlineRepository.hasCachedStories; final bool hasCachedStories = await _offlineRepository.hasCachedStories;
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled; final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
final int pageSize = _getPageSize(isComplexTile: isComplexTile); final int pageSize = getPageSize(isComplexTile: isComplexTile);
emit( emit(
const StoriesState.init().copyWith( const StoriesState.init().copyWith(
offlineReading: hasCachedStories && offlineReading: hasCachedStories &&
@ -92,44 +83,45 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
storiesToBeDownloaded: state.storiesToBeDownloaded, storiesToBeDownloaded: state.storiesToBeDownloaded,
), ),
); );
for (final StoryType type in types) { for (final StoryType type in StoryType.values) {
await loadStories(of: type, emit: emit); await loadStories(type: type, emit: emit);
} }
} }
Future<void> loadStories({ Future<void> loadStories({
required StoryType of, required StoryType type,
required Emitter<StoriesState> emit, required Emitter<StoriesState> emit,
}) async { }) async {
if (state.offlineReading) { if (state.offlineReading) {
final List<int> ids = await _offlineRepository.getCachedStoryIds(of: of); final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type);
emit( emit(
state state
.copyWithStoryIdsUpdated(of: of, to: ids) .copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0), .copyWithCurrentPageUpdated(type: type, to: 0),
); );
_offlineRepository _offlineRepository
.getCachedStoriesStream( .getCachedStoriesStream(
ids: ids.sublist(0, min(ids.length, state.currentPageSize)), ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
) )
.listen((Story story) { .listen((Story story) {
add(StoryLoaded(story: story, type: of)); add(StoryLoaded(story: story, type: type));
}).onDone(() { }).onDone(() {
add(StoriesLoaded(type: of)); add(StoriesLoaded(type: type));
}); });
} else { } else {
final List<int> ids = await _storiesRepository.fetchStoryIds(of: of); final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
emit( emit(
state state
.copyWithStoryIdsUpdated(of: of, to: ids) .copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0), .copyWithCurrentPageUpdated(type: type, to: 0),
); );
_storiesRepository _storiesRepository
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize)) .fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
.listen((Story story) { .listen((Story story) {
add(StoryLoaded(story: story, type: of)); add(StoryLoaded(story: story, type: type));
}).onDone(() { }).onDone(() {
add(StoriesLoaded(type: of)); add(StoriesLoaded(type: type));
}); });
} }
} }
@ -140,7 +132,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
) async { ) async {
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
of: event.type, type: event.type,
to: StoriesStatus.loading, to: StoriesStatus.loading,
), ),
); );
@ -148,27 +140,29 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
if (state.offlineReading) { if (state.offlineReading) {
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
of: event.type, type: event.type,
to: StoriesStatus.loaded, to: StoriesStatus.loaded,
), ),
); );
} else { } else {
emit(state.copyWithRefreshed(of: event.type)); emit(state.copyWithRefreshed(type: event.type));
await loadStories(of: event.type, emit: emit); await loadStories(type: event.type, emit: emit);
} }
} }
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) { void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
of: event.type, type: event.type,
to: StoriesStatus.loading, to: StoriesStatus.loading,
), ),
); );
final int currentPage = state.currentPageByType[event.type]!; final int currentPage = state.currentPageByType[event.type]!;
final int len = state.storyIdsByType[event.type]!.length; 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 currentPageSize = state.currentPageSize;
final int lower = currentPageSize * (currentPage + 1); final int lower = currentPageSize * (currentPage + 1);
int upper = currentPageSize + lower; int upper = currentPageSize + lower;
@ -218,7 +212,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} else { } else {
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
of: event.type, type: event.type,
to: StoriesStatus.loaded, to: StoriesStatus.loaded,
), ),
); );
@ -232,7 +226,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final bool hasRead = await _preferenceRepository.hasRead(event.story.id); final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
emit( emit(
state.copyWithStoryAdded( state.copyWithStoryAdded(
of: event.type, type: event.type,
story: event.story, story: event.story,
hasRead: hasRead, hasRead: hasRead,
), ),
@ -240,7 +234,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} }
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) { 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( Future<void> onDownload(
@ -258,12 +254,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.deleteAllComments(); await _offlineRepository.deleteAllComments();
final Set<int> prioritizedIds = <int>{}; 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); ..remove(StoryType.latest);
for (final StoryType type in prioritizedTypes) { for (final StoryType type in prioritizedTypes) {
final List<int> ids = await _storiesRepository.fetchStoryIds(of: type); final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
await _offlineRepository.cacheStoryIds(of: type, ids: ids); await _offlineRepository.cacheStoryIds(type: type, ids: ids);
prioritizedIds.addAll(ids); prioritizedIds.addAll(ids);
} }
@ -283,9 +282,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final Set<int> latestIds = <int>{}; final Set<int> latestIds = <int>{};
final List<int> ids = await _storiesRepository.fetchStoryIds( 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); latestIds.addAll(ids);
await fetchAndCacheStories( await fetchAndCacheStories(
@ -311,10 +310,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
downloadStatus: StoriesDownloadStatus.canceled, downloadStatus: StoriesDownloadStatus.canceled,
), ),
); );
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
} }
Future<void> fetchAndCacheStories( Future<void> fetchAndCacheStories(
@ -322,11 +317,25 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required bool includingWebPage, required bool includingWebPage,
required bool isPrioritized, required bool isPrioritized,
}) async { }) async {
final List<StreamSubscription<Comment>> downloadStreams =
<StreamSubscription<Comment>>[];
for (final int id in ids) { for (final int id in ids) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) break; if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading');
for (final StreamSubscription<Comment> stream in downloadStreams) {
await stream.cancel();
}
_logger.d('deleting downloaded contents');
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
break;
}
_logger.d('fetching story $id'); _logger.d('fetching story $id');
final Story? story = await _storiesRepository.fetchStoryBy(id); final Story? story = await _storiesRepository.fetchStory(id: id);
if (story == null) { if (story == null) {
if (isPrioritized) { if (isPrioritized) {
@ -349,17 +358,37 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.cacheUrl(url: story.url); 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) .fetchAllChildrenComments(ids: story.kids)
.whereType<Comment>() .whereType<Comment>()
.listen( .listen(
(Comment comment) { (Comment comment) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading from comments stream');
downloadStream?.cancel();
return;
}
_logger.d('fetched comment ${comment.id}'); _logger.d('fetched comment ${comment.id}');
unawaited( unawaited(
_offlineRepository.cacheComment(comment: comment), _offlineRepository.cacheComment(comment: comment),
); );
}, },
).onDone(() => add(StoryDownloaded(skipped: false))); )..onDone(() {
_logger.d(
'''finished downloading story ${story.id} with ${story.descendants} comments''',
);
add(StoryDownloaded(skipped: false));
});
downloadStreams.add(downloadStream);
} }
} }
@ -443,7 +472,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
bool hasRead(Story story) => state.readStoriesIds.contains(story.id); bool hasRead(Story story) => state.readStoriesIds.contains(story.id);
int _getPageSize({required bool isComplexTile}) { int getPageSize({required bool isComplexTile}) {
int pageSize = isComplexTile ? _smallPageSize : _largePageSize; int pageSize = isComplexTile ? _smallPageSize : _largePageSize;
if (deviceScreenType != DeviceScreenType.mobile) { if (deviceScreenType != DeviceScreenType.mobile) {

View File

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

View File

@ -56,9 +56,11 @@ abstract class Constants {
'ʕ•́ᴥ•̀ʔっ', 'ʕ•́ᴥ•̀ʔっ',
'(ㆆ_ㆆ)', '(ㆆ_ㆆ)',
].pickRandomly()!; ].pickRandomly()!;
static final String errorMessage = 'Something went wrong...$sadFace';
} }
abstract class RegExpConstants { abstract class RegExpConstants {
static const String linkSuffix = r'(\)|])(.)*$'; static const String linkSuffix = r'(\)|]|,|\*)(.)*$';
static const String number = '[0-9]+'; static const String number = '[0-9]+';
} }

View File

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

View File

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

View File

@ -106,7 +106,7 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item; final Item item = state.item;
final Item updatedItem = state.offlineReading final Item updatedItem = state.offlineReading
? item ? item
: await _storiesRepository.fetchItemBy(id: item.id) ?? item; : await _storiesRepository.fetchItem(id: item.id) ?? item;
final List<int> kids = sortKids(updatedItem.kids); final List<int> kids = sortKids(updatedItem.kids);
emit(state.copyWith(item: updatedItem)); emit(state.copyWith(item: updatedItem));
@ -173,7 +173,7 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item; final Item item = state.item;
final Item updatedItem = final Item updatedItem =
await _storiesRepository.fetchItemBy(id: item.id) ?? item; await _storiesRepository.fetchItem(id: item.id) ?? item;
final List<int> kids = sortKids(updatedItem.kids); final List<int> kids = sortKids(updatedItem.kids);
if (state.fetchMode == FetchMode.lazy) { if (state.fetchMode == FetchMode.lazy) {

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; if (item == null) return;

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ class UserCubit extends Cubit<UserState> {
void init({required String userId}) { void init({required String userId}) {
emit(state.copyWith(status: UserStatus.loading)); 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)); emit(state.copyWith(user: user, status: UserStatus.loaded));
}).onError((_, __) { }).onError((_, __) {
emit(state.copyWith(status: UserStatus.failure)); emit(state.copyWith(status: UserStatus.failure));

View File

@ -2,6 +2,8 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/styles/styles.dart';
extension ContextExtension on BuildContext { extension ContextExtension on BuildContext {
T? tryRead<T>() { 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 { Rect? get rect {
final RenderBox? box = findRenderObject() as RenderBox?; final RenderBox? box = findRenderObject() as RenderBox?;
final Rect? rect = final Rect? rect =

View File

@ -21,22 +21,15 @@ extension StateExtension on State {
VoidCallback? action, VoidCallback? action,
String? label, String? label,
}) { }) {
ScaffoldMessenger.of(context).showSnackBar( context.showSnackBar(
SnackBar( content: content,
backgroundColor: Palette.deepOrange, action: action,
content: Text(content), label: label,
action: action != null && label != null
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
); );
} }
void showErrorSnackBar() => context.showErrorSnackBar();
Future<void>? goToItemScreen({ Future<void>? goToItemScreen({
required ItemScreenArgs args, required ItemScreenArgs args,
bool forceNewScreen = false, bool forceNewScreen = false,
@ -70,7 +63,6 @@ extension StateExtension on State {
return MorePopupMenu( return MorePopupMenu(
item: item, item: item,
isBlocked: isBlocked, isBlocked: isBlocked,
showSnackBar: showSnackBar,
onStoryLinkTapped: onStoryLinkTapped, onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped, onLoginTapped: onLoginTapped,
); );
@ -103,7 +95,7 @@ extension StateExtension on State {
if (id != null) { if (id != null) {
await locator await locator
.get<StoriesRepository>() .get<StoriesRepository>()
.fetchItemBy(id: id) .fetchItem(id: id)
.then((Item? item) { .then((Item? item) {
if (mounted) { if (mounted) {
if (item != null) { if (item != null) {
@ -119,11 +111,45 @@ extension StateExtension on State {
} }
} }
void onShareTapped(Item item, Rect? rect) { Future<void> onShareTapped(Item item, Rect? rect) async {
Share.share( late final String? linkToShare;
'https://news.ycombinator.com/item?id=${item.id}', if (item.url.isNotEmpty) {
sharePositionOrigin: rect, 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) { void onFlagTapped(Item item) {

View File

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

View File

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

View File

@ -1,6 +1,10 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.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 { class Item extends Equatable {
const Item({ const Item({
required this.id, required this.id,
@ -97,6 +101,7 @@ class Item extends Equatable {
'deleted': deleted, 'deleted': deleted,
'type': type, 'type': type,
'parts': parts, 'parts': parts,
'parent': parent,
}; };
} }

View File

@ -9,4 +9,5 @@ export 'post_data.dart';
export 'preference.dart'; export 'preference.dart';
export 'search_params.dart'; export 'search_params.dart';
export 'story.dart'; export 'story.dart';
export 'story_type.dart';
export 'user.dart'; export 'user.dart';

View File

@ -24,22 +24,7 @@ class PollOption extends Item {
PollOption.empty() PollOption.empty()
: ratio = 0, : ratio = 0,
super( super.empty();
id: 0,
score: 0,
descendants: 0,
time: 0,
by: '',
title: '',
url: '',
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
PollOption.fromJson(super.json) PollOption.fromJson(super.json)
: ratio = 0, : ratio = 0,
@ -67,19 +52,7 @@ class PollOption extends Item {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return <String, dynamic>{ return <String, dynamic>{
'descendants': descendants, ...super.toJson(),
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
'parts': parts,
'ratio': ratio, 'ratio': ratio,
}; };
} }
@ -90,22 +63,4 @@ class PollOption extends Item {
const JsonEncoder.withIndent(' ').convert(this); const JsonEncoder.withIndent(' ').convert(this);
return 'PollOption $prettyString'; 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 'dart:io';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
@ -13,26 +14,29 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
Preference<T> copyWith({required T? val}); Preference<T> copyWith({required T? val});
static List<Preference<dynamic>> allPreferences = <Preference<dynamic>>[ static final List<Preference<dynamic>> allPreferences =
// Order of these first three preferences does not matter. UnmodifiableListView<Preference<dynamic>>(
FetchModePreference(), <Preference<dynamic>>[
CommentsOrderPreference(), // Order of these first four preferences does not matter.
FontSizePreference(), FetchModePreference(),
TabOrderPreference(), CommentsOrderPreference(),
// Order of items below matters and FontSizePreference(),
// reflects the order on settings screen. TabOrderPreference(),
const DisplayModePreference(), // Order of items below matters and
const MetadataModePreference(), // reflects the order on settings screen.
const StoryUrlModePreference(), const DisplayModePreference(),
const NotificationModePreference(), const MetadataModePreference(),
const SwipeGesturePreference(), const StoryUrlModePreference(),
const CollapseModePreference(), const NotificationModePreference(),
NavigationModePreference(), const SwipeGesturePreference(),
const ReaderModePreference(), const CollapseModePreference(),
const MarkReadStoriesModePreference(), NavigationModePreference(),
const EyeCandyModePreference(), const ReaderModePreference(),
const TrueDarkModePreference(), const MarkReadStoriesModePreference(),
]; const EyeCandyModePreference(),
const TrueDarkModePreference(),
],
);
@override @override
List<Object?> get props => <Object?>[key]; List<Object?> get props => <Object?>[key];
@ -81,7 +85,7 @@ class SwipeGesturePreference extends BooleanPreference {
@override @override
String get subtitle => String get subtitle =>
'''Enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.'''; '''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
} }
class NotificationModePreference extends BooleanPreference { class NotificationModePreference extends BooleanPreference {
@ -118,6 +122,10 @@ class CollapseModePreference extends BooleanPreference {
@override @override
String get title => 'Tap Anywhere to Collapse'; 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 /// The value deciding whether or not the story

View File

@ -1,41 +1,6 @@
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item.dart';
enum StoryType {
top('topstories'),
best('beststories'),
latest('newstories'),
ask('askstories'),
show('showstories');
const StoryType(this.path);
final String path;
String get label {
switch (this) {
case StoryType.top:
return 'TOP';
case StoryType.best:
return 'BEST';
case StoryType.latest:
return 'NEW';
case StoryType.ask:
return 'ASK';
case StoryType.show:
return 'SHOW';
}
}
static int convertToSettingsValue(List<StoryType> tabs) {
return int.parse(
tabs
.map((StoryType e) => e.index.toString())
.reduce((String value, String element) => '$value$element'),
);
}
}
class Story extends Item { class Story extends Item {
const Story({ const Story({
required super.descendants, required super.descendants,
@ -55,23 +20,7 @@ class Story extends Item {
parent: 0, parent: 0,
); );
Story.empty() Story.empty() : super.empty();
: super(
id: 0,
score: 0,
descendants: 0,
time: 0,
by: '',
title: '',
url: '',
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
Story.placeholder() Story.placeholder()
: super( : super(
@ -105,25 +54,6 @@ class Story extends Item {
return authority; 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 @override
String toString() { String toString() {
// final String prettyString = // final String prettyString =
@ -131,23 +61,4 @@ class Story extends Item {
// return 'Story $prettyString'; // return 'Story $prettyString';
return 'Story $id'; return 'Story $id';
} }
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
text,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
} }

View File

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

View File

@ -2,10 +2,16 @@ import 'dart:async';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.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/postable_repository.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/preference_repository.dart';
import 'package:logger/logger.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 { class AuthRepository extends PostableRepository {
AuthRepository({ AuthRepository({
super.dio, super.dio,
@ -18,8 +24,6 @@ class AuthRepository extends PostableRepository {
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final Logger _logger; final Logger _logger;
static const String _authority = 'news.ycombinator.com';
Future<bool> get loggedIn async => _preferenceRepository.loggedIn; Future<bool> get loggedIn async => _preferenceRepository.loggedIn;
Future<String?> get username async => _preferenceRepository.username; Future<String?> get username async => _preferenceRepository.username;
@ -30,7 +34,7 @@ class AuthRepository extends PostableRepository {
required String username, required String username,
required String password, required String password,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'login'); final Uri uri = Uri.https(authority, 'login');
final PostDataMixin data = LoginPostData( final PostDataMixin data = LoginPostData(
acct: username, acct: username,
pw: password, pw: password,
@ -64,7 +68,7 @@ class AuthRepository extends PostableRepository {
required int id, required int id,
required bool flag, required bool flag,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'flag'); final Uri uri = Uri.https(authority, 'flag');
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; final String? password = await _preferenceRepository.password;
final PostDataMixin data = FlagPostData( final PostDataMixin data = FlagPostData(
@ -81,7 +85,7 @@ class AuthRepository extends PostableRepository {
required int id, required int id,
required bool favorite, required bool favorite,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'fave'); final Uri uri = Uri.https(authority, 'fave');
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; final String? password = await _preferenceRepository.password;
final PostDataMixin data = FavoritePostData( final PostDataMixin data = FavoritePostData(
@ -98,7 +102,7 @@ class AuthRepository extends PostableRepository {
required int id, required int id,
required bool upvote, required bool upvote,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'vote'); final Uri uri = Uri.https(authority, 'vote');
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; final String? password = await _preferenceRepository.password;
final PostDataMixin data = VotePostData( final PostDataMixin data = VotePostData(
@ -115,7 +119,7 @@ class AuthRepository extends PostableRepository {
required int id, required int id,
required bool downvote, required bool downvote,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'vote'); final Uri uri = Uri.https(authority, 'vote');
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; final String? password = await _preferenceRepository.password;
final PostDataMixin data = VotePostData( final PostDataMixin data = VotePostData(

View File

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

View File

@ -7,6 +7,7 @@ import 'package:hacki/repositories/postable_repository.dart';
import 'package:hacki/repositories/preference_repository.dart'; import 'package:hacki/repositories/preference_repository.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
/// [PostRepository] is for posting contents to Hacker News.
class PostRepository extends PostableRepository { class PostRepository extends PostableRepository {
PostRepository({super.dio, PreferenceRepository? storageRepository}) PostRepository({super.dio, PreferenceRepository? storageRepository})
: _preferenceRepository = : _preferenceRepository =
@ -14,15 +15,13 @@ class PostRepository extends PostableRepository {
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
static const String _authority = 'news.ycombinator.com';
Future<bool> comment({ Future<bool> comment({
required int parentId, required int parentId,
required String text, required String text,
}) async { }) async {
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; 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) { if (username == null || password == null) {
return false; return false;
@ -54,7 +53,7 @@ class PostRepository extends PostableRepository {
return false; return false;
} }
final Response<List<int>> formResponse = await _getFormResponse( final Response<List<int>> formResponse = await getFormResponse(
username: username, username: username,
password: password, password: password,
path: 'submitlink', path: 'submitlink',
@ -69,7 +68,7 @@ class PostRepository extends PostableRepository {
final String? cookie = final String? cookie =
formResponse.headers.value(HttpHeaders.setCookieHeader); formResponse.headers.value(HttpHeaders.setCookieHeader);
final Uri uri = Uri.https(_authority, 'r'); final Uri uri = Uri.https(authority, 'r');
final PostDataMixin data = SubmitPostData( final PostDataMixin data = SubmitPostData(
fnid: formValues['fnid']!, fnid: formValues['fnid']!,
fnop: formValues['fnop']!, fnop: formValues['fnop']!,
@ -97,7 +96,7 @@ class PostRepository extends PostableRepository {
return false; return false;
} }
final Response<List<int>> formResponse = await _getFormResponse( final Response<List<int>> formResponse = await getFormResponse(
username: username, username: username,
password: password, password: password,
id: id, id: id,
@ -113,7 +112,7 @@ class PostRepository extends PostableRepository {
final String? cookie = final String? cookie =
formResponse.headers.value(HttpHeaders.setCookieHeader); formResponse.headers.value(HttpHeaders.setCookieHeader);
final Uri uri = Uri.https(_authority, 'xedit'); final Uri uri = Uri.https(authority, 'xedit');
final PostDataMixin data = EditPostData( final PostDataMixin data = EditPostData(
hmac: formValues['hmac']!, hmac: formValues['hmac']!,
id: id, id: id,
@ -126,28 +125,4 @@ class PostRepository extends PostableRepository {
cookie: cookie, 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:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/models/models.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'; import 'package:hacki/utils/service_exception.dart';
/// [PostableRepository] is solely for hosting functionalities shared between
/// [AuthRepository] and [PostRepository].
class PostableRepository { class PostableRepository {
PostableRepository({ PostableRepository({
Dio? dio, Dio? dio,
this.authority = 'news.ycombinator.com',
}) : _dio = dio ?? Dio(); }) : _dio = dio ?? Dio();
final Dio _dio; final Dio _dio;
@protected
final String authority;
@protected @protected
Future<bool> performDefaultPost( Future<bool> performDefaultPost(
Uri uri, Uri uri,
@ -60,4 +68,29 @@ class PostableRepository {
throw ServiceException(e.message); 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:shared_preferences/shared_preferences.dart';
import 'package:synced_shared_preferences/synced_shared_preferences.dart'; import 'package:synced_shared_preferences/synced_shared_preferences.dart';
/// [PreferenceRepository] is for storing user preferences.
class PreferenceRepository { class PreferenceRepository {
PreferenceRepository({ PreferenceRepository({
SyncedSharedPreferences? syncedPrefs, SyncedSharedPreferences? syncedPrefs,

View File

@ -3,6 +3,9 @@ import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.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 { class SearchRepository {
SearchRepository({Dio? dio}) : _dio = dio ?? Dio(); SearchRepository({Dio? dio}) : _dio = dio ?? Dio();

View File

@ -7,7 +7,10 @@ import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart'; import 'package:sembast/sembast_io.dart';
/// [SembastRepository] is for storing stories and comments for faster loading. /// [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 { class SembastRepository {
SembastRepository({Database? database}) { SembastRepository({Database? database}) {
if (database == null) { if (database == null) {

View File

@ -4,6 +4,11 @@ import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:tuple/tuple.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 { class StoriesRepository {
StoriesRepository({ StoriesRepository({
FirebaseClient? firebaseClient, FirebaseClient? firebaseClient,
@ -12,9 +17,66 @@ class StoriesRepository {
final FirebaseClient _firebaseClient; final FirebaseClient _firebaseClient;
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/'; 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 final User user = await _firebaseClient
.get('${_baseUrl}user/$userId.json') .get('${_baseUrl}user/$id.json')
.then((dynamic val) { .then((dynamic val) {
final Map<String, dynamic> json = val as Map<String, dynamic>; final Map<String, dynamic> json = val as Map<String, dynamic>;
final User user = User.fromJson(json); final User user = User.fromJson(json);
@ -24,9 +86,27 @@ class StoriesRepository {
return user; 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 final List<int> ids = await _firebaseClient
.get('$_baseUrl${of.path}.json') .get('$_baseUrl${type.path}.json')
.then((dynamic val) { .then((dynamic val) {
final List<int> ids = (val as List<dynamic>).cast<int>(); final List<int> ids = (val as List<dynamic>).cast<int>();
return ids; return ids;
@ -35,11 +115,10 @@ class StoriesRepository {
return ids; return ids;
} }
Future<Story?> fetchStoryBy(int id) async { /// Fetch a [Story] based on its id.
final Story? story = await _firebaseClient Future<Story?> fetchStory({required int id}) async {
.get('${_baseUrl}item/$id.json') final Story? story =
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?)) await _fetchItemJson(id).then((Map<String, dynamic>? json) {
.then((Map<String, dynamic>? json) {
if (json == null) return null; if (json == null) return null;
final Story story = Story.fromJson(json); final Story story = Story.fromJson(json);
return story; return story;
@ -48,6 +127,90 @@ class StoriesRepository {
return story; 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({ Stream<Comment> fetchCommentsStream({
required List<int> ids, required List<int> ids,
int level = 0, int level = 0,
@ -56,10 +219,8 @@ class StoriesRepository {
for (final int id in ids) { for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level); Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient comment ??=
.get('${_baseUrl}item/$id.json') await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null; if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level); final Comment comment = Comment.fromJson(json, level: level);
@ -73,6 +234,8 @@ class StoriesRepository {
return; return;
} }
/// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream.
Stream<Comment> fetchAllCommentsRecursivelyStream({ Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids, required List<int> ids,
int level = 0, int level = 0,
@ -81,10 +244,8 @@ class StoriesRepository {
for (final int id in ids) { for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level); Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient comment ??=
.get('${_baseUrl}item/$id.json') await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null; if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level); final Comment comment = Comment.fromJson(json, level: level);
@ -104,19 +265,19 @@ class StoriesRepository {
return; return;
} }
/// Fetch a list of [Item] based on ids and return results
/// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* { Stream<Item> fetchItemsStream({required List<int> ids}) async* {
for (final int id in ids) { for (final int id in ids) {
final Item? item = await _firebaseClient final Item? item =
.get('${_baseUrl}item/$id.json') await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null; if (json == null) return null;
final String type = json['type'] as String; final String type = json['type'] as String;
if (type == 'story' || type == 'job') { if (type == 'story' || type == 'job') {
final Story story = Story.fromJson(json); final Story story = Story.fromJson(json);
return story; return story;
} else if (json['type'] == 'comment') { } else if (type == 'comment') {
final Comment comment = Comment.fromJson(json); final Comment comment = Comment.fromJson(json);
return comment; 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* { Stream<Story> fetchStoriesStream({required List<int> ids}) async* {
for (final int id in ids) { for (final int id in ids) {
final Story? story = await _firebaseClient final Story? story =
.get('${_baseUrl}item/$id.json') await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null; if (json == null) return null;
final Story story = Story.fromJson(json); final Story story = Story.fromJson(json);
return story; 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* { Stream<PollOption> fetchPollOptionsStream({required List<int> ids}) async* {
for (final int id in ids) { for (final int id in ids) {
final PollOption? option = await _firebaseClient final PollOption? option =
.get('${_baseUrl}item/$id.json') await _fetchRawItemJson(id).then((dynamic json) async {
.then((dynamic json) async {
if (json == null) return null; if (json == null) return null;
final PollOption option = final PollOption option =
PollOption.fromJson(json as Map<String, dynamic>); PollOption.fromJson(json as Map<String, dynamic>);
@ -163,143 +325,10 @@ class StoriesRepository {
} }
} }
Future<Comment?> fetchCommentBy({required int id}) async { /// Fetch a list of [Comment] based on ids recursively.
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(),
);
}
Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* { Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* {
for (final int id in ids) { for (final int id in ids) {
final Comment? comment = await fetchCommentBy(id: id); final Comment? comment = await fetchComment(id: id);
if (comment != null) { if (comment != null) {
yield comment; yield comment;
yield* fetchAllChildrenComments(ids: comment.kids); 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; if (json == null) return null;
final String text = json['text'] as String? ?? ''; final String text = json['text'] as String? ?? '';
final String parsedText = await compute<String, String>( final String parsedText = await compute<String, String>(

View File

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

View File

@ -1,5 +1,3 @@
// ignore_for_file: comment_references
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -11,8 +9,8 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/widgets/widgets.dart'; import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
@ -33,7 +31,7 @@ class ItemScreenArgs extends Equatable {
final List<Comment>? targetComments; final List<Comment>? targetComments;
/// when a user is trying to view a sub-thread from a main thread, we don't /// 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]. /// all, comments cached in [CommentCache].
final bool useCommentCache; final bool useCommentCache;
@ -175,16 +173,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
SchedulerBinding.instance SchedulerBinding.instance
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
if (!isTesting) { FeatureDiscovery.discoverFeatures(
FeatureDiscovery.discoverFeatures( context,
context, <String>{
const <String>{ Constants.featurePinToTop,
Constants.featurePinToTop, Constants.featureAddStoryToFavList,
Constants.featureAddStoryToFavList, Constants.featureOpenStoryInWebView,
Constants.featureOpenStoryInWebView, },
}, );
);
}
}) })
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context); final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -239,12 +235,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<EditCubit>().onReplySubmittedSuccessfully(); context.read<EditCubit>().onReplySubmittedSuccessfully();
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) { } else if (postState.status == PostStatus.failure) {
showSnackBar( showErrorSnackBar();
content: 'Something went wrong...'
'${Constants.sadFace}',
label: 'Okay',
action: ScaffoldMessenger.of(context).hideCurrentSnackBar,
);
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} }
}, },
@ -323,8 +314,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
.withOpacity(0.6), .withOpacity(0.6),
item: widget.item, item: widget.item,
scrollController: scrollController, scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
splitViewEnabled: state.enabled, splitViewEnabled: state.enabled,
expanded: state.expanded, expanded: state.expanded,
onZoomTap: onZoomTap:
@ -364,8 +353,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
Theme.of(context).canvasColor.withOpacity(0.6), Theme.of(context).canvasColor.withOpacity(0.6),
item: widget.item, item: widget.item,
scrollController: scrollController, scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onFontSizeTap: onFontSizeTapped, onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey, fontSizeIconButtonKey: fontSizeIconButtonKey,
), ),
@ -401,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() { void onFontSizeTapped() {
const Offset offset = Offset.zero; const Offset offset = Offset.zero;
final RenderBox overlay = final RenderBox overlay =

View File

@ -11,8 +11,6 @@ class CustomAppBar extends AppBar {
required ScrollController scrollController, required ScrollController scrollController,
required Item item, required Item item,
required Color super.backgroundColor, required Color super.backgroundColor,
required Future<bool> Function() onBackgroundTap,
required Future<bool> Function() onDismiss,
required VoidCallback onFontSizeTap, required VoidCallback onFontSizeTap,
required GlobalKey fontSizeIconButtonKey, required GlobalKey fontSizeIconButtonKey,
bool splitViewEnabled = false, bool splitViewEnabled = false,
@ -41,26 +39,26 @@ class CustomAppBar extends AppBar {
), ),
IconButton( IconButton(
key: fontSizeIconButtonKey, key: fontSizeIconButtonKey,
icon: const Icon( icon: Text(
Icons.format_size, String.fromCharCode(FeatherIcons.type.codePoint),
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
),
), ),
onPressed: onFontSizeTap, onPressed: onFontSizeTap,
), ),
if (item is Story) if (item is Story)
PinIconButton( PinIconButton(
story: item, story: item,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
), ),
FavIconButton( FavIconButton(
storyId: item.id, storyId: item.id,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
), ),
LinkIconButton( LinkIconButton(
storyId: item.id, 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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
class FavIconButton extends StatelessWidget { class FavIconButton extends StatelessWidget {
const FavIconButton({ const FavIconButton({
super.key, super.key,
required this.storyId, required this.storyId,
required this.onBackgroundTap,
required this.onDismiss,
}); });
final int storyId; final int storyId;
final Future<bool> Function() onBackgroundTap;
final Future<bool> Function() onDismiss;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -27,15 +21,7 @@ class FavIconButton extends StatelessWidget {
final bool isFav = favState.favIds.contains(storyId); final bool isFav = favState.favIds.contains(storyId);
return IconButton( return IconButton(
tooltip: 'Add to favorites', tooltip: 'Add to favorites',
icon: DescribedFeatureOverlay( icon: CustomDescribedFeatureOverlay(
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: Icon( tapTarget: Icon(
isFav ? Icons.favorite : Icons.favorite_border, isFav ? Icons.favorite : Icons.favorite_border,
color: Palette.white, 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/material.dart';
import 'package:flutter/services.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -11,40 +8,28 @@ class LinkIconButton extends StatelessWidget {
const LinkIconButton({ const LinkIconButton({
super.key, super.key,
required this.storyId, required this.storyId,
required this.onBackgroundTap,
required this.onDismiss,
}); });
final int storyId; final int storyId;
final Future<bool> Function() onBackgroundTap;
final Future<bool> Function() onDismiss;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( return IconButton(
tooltip: 'Open this story in browser', tooltip: 'Open this story in browser',
icon: DescribedFeatureOverlay( icon: const CustomDescribedFeatureOverlay(
onBackgroundTap: onBackgroundTap, tapTarget: Icon(
onDismiss: onDismiss,
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.stream, Icons.stream,
color: Palette.white, color: Palette.white,
), ),
featureId: Constants.featureOpenStoryInWebView, featureId: Constants.featureOpenStoryInWebView,
title: const Text('Open in Browser'), title: Text('Open in Browser'),
description: const Text( description: Text(
'Want more than just reading and replying? ' 'Want more than just reading and replying? '
'You can tap here to open this story in a ' 'You can tap here to open this story in a '
'browser.', 'browser.',
style: TextStyle(fontSize: TextDimens.pt16), style: TextStyle(fontSize: TextDimens.pt16),
), ),
child: const Icon( child: Icon(
Icons.stream, Icons.stream,
), ),
), ),

View File

@ -87,13 +87,13 @@ class LoginDialog extends StatelessWidget {
height: Dimens.pt16, height: Dimens.pt16,
), ),
if (state.status == AuthStatus.failure) if (state.status == AuthStatus.failure)
const Padding( Padding(
padding: EdgeInsets.only( padding: const EdgeInsets.only(
left: Dimens.pt18, left: Dimens.pt18,
), ),
child: Text( child: Text(
'Something went wrong...', Constants.errorMessage,
style: TextStyle( style: const TextStyle(
color: Palette.grey, color: Palette.grey,
fontSize: TextDimens.pt12, fontSize: TextDimens.pt12,
), ),

View File

@ -342,13 +342,13 @@ class _ParentItemSection extends StatelessWidget {
), ),
child: RichText( child: RichText(
textAlign: TextAlign.center, textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
text: TextSpan( text: TextSpan(
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: MediaQuery.of( fontSize: prefState.fontSize.fontSize,
context,
).textScaleFactor *
prefState.fontSize.fontSize,
color: Theme.of(context) color: Theme.of(context)
.textTheme .textTheme
.bodyLarge .bodyLarge
@ -359,10 +359,7 @@ class _ParentItemSection extends StatelessWidget {
text: state.item.title, text: state.item.title,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: MediaQuery.of( fontSize: prefState.fontSize.fontSize,
context,
).textScaleFactor *
prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty color: state.item.url.isNotEmpty
? Palette.orange ? Palette.orange
: null, : null,
@ -374,10 +371,8 @@ class _ParentItemSection extends StatelessWidget {
''' (${(state.item as Story).readableUrl})''', ''' (${(state.item as Story).readableUrl})''',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: MediaQuery.of( fontSize:
context, prefState.fontSize.fontSize - 4,
).textScaleFactor *
(prefState.fontSize.fontSize - 4),
color: Palette.orange, color: Palette.orange,
), ),
), ),
@ -391,36 +386,39 @@ class _ParentItemSection extends StatelessWidget {
height: Dimens.pt6, height: Dimens.pt6,
), ),
if (state.item.text.isNotEmpty) if (state.item.text.isNotEmpty)
Padding( SizedBox(
padding: const EdgeInsets.symmetric( width: double.infinity,
horizontal: Dimens.pt10, child: Padding(
), padding: const EdgeInsets.symmetric(
child: SelectableLinkify( horizontal: Dimens.pt10,
text: state.item.text,
style: TextStyle(
fontSize: MediaQuery.of(context).textScaleFactor *
context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
), ),
linkStyle: TextStyle( child: SelectableLinkify(
fontSize: MediaQuery.of(context).textScaleFactor * text: state.item.text,
context textScaleFactor:
.read<PreferenceCubit>() MediaQuery.of(context).textScaleFactor,
.state style: TextStyle(
.fontSize fontSize: context
.fontSize, .read<PreferenceCubit>()
color: Palette.orange, .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

@ -15,18 +15,12 @@ class MorePopupMenu extends StatelessWidget {
super.key, super.key,
required this.item, required this.item,
required this.isBlocked, required this.isBlocked,
required this.showSnackBar,
required this.onStoryLinkTapped, required this.onStoryLinkTapped,
required this.onLoginTapped, required this.onLoginTapped,
}); });
final Item item; final Item item;
final bool isBlocked; final bool isBlocked;
final void Function({
required String content,
VoidCallback? action,
String? label,
}) showSnackBar;
final ValueChanged<String> onStoryLinkTapped; final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped; final VoidCallback onLoginTapped;
@ -43,24 +37,26 @@ class MorePopupMenu extends StatelessWidget {
}, },
listener: (BuildContext context, VoteState voteState) { listener: (BuildContext context, VoteState voteState) {
if (voteState.status == VoteStatus.submitted) { if (voteState.status == VoteStatus.submitted) {
showSnackBar(content: 'Vote submitted successfully.'); context.showSnackBar(content: 'Vote submitted successfully.');
} else if (voteState.status == VoteStatus.canceled) { } else if (voteState.status == VoteStatus.canceled) {
showSnackBar(content: 'Vote canceled.'); context.showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) { } else if (voteState.status == VoteStatus.failure) {
showSnackBar(content: 'Something went wrong...'); context.showErrorSnackBar();
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) { VoteStatus.failureKarmaBelowThreshold) {
showSnackBar( context.showSnackBar(
content: "You can't downvote because you are karmaly broke.", content: "You can't downvote because you are karmaly broke.",
); );
} else if (voteState.status == VoteStatus.failureNotLoggedIn) { } else if (voteState.status == VoteStatus.failureNotLoggedIn) {
showSnackBar( context.showSnackBar(
content: 'Not logged in, no voting! (;O´)o', content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped, action: onLoginTapped,
label: 'Log in', label: 'Log in',
); );
} else if (voteState.status == VoteStatus.failureBeHumble) { } 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( Navigator.pop(

View File

@ -1,26 +1,21 @@
import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
class PinIconButton extends StatelessWidget { class PinIconButton extends StatelessWidget {
const PinIconButton({ const PinIconButton({
super.key, super.key,
required this.story, required this.story,
required this.onBackgroundTap,
required this.onDismiss,
}); });
final Story story; final Story story;
final Future<bool> Function() onBackgroundTap;
final Future<bool> Function() onDismiss;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -33,15 +28,7 @@ class PinIconButton extends StatelessWidget {
offset: const Offset(2, 0), offset: const Offset(2, 0),
child: IconButton( child: IconButton(
tooltip: 'Pin to home screen', tooltip: 'Pin to home screen',
icon: DescribedFeatureOverlay( icon: CustomDescribedFeatureOverlay(
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: Icon( tapTarget: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined, pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Palette.white, 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:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart'; import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
@ -61,36 +62,29 @@ class PollView extends StatelessWidget {
listener: (BuildContext context, VoteState voteState) { listener: (BuildContext context, VoteState voteState) {
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
if (voteState.status == VoteStatus.submitted) { if (voteState.status == VoteStatus.submitted) {
showSnackBar( context.showSnackBar(
context,
content: 'Vote submitted successfully.', content: 'Vote submitted successfully.',
); );
} else if (voteState.status == VoteStatus.canceled) { } else if (voteState.status == VoteStatus.canceled) {
showSnackBar(context, content: 'Vote canceled.'); context.showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) { } else if (voteState.status == VoteStatus.failure) {
showSnackBar( context.showErrorSnackBar();
context,
content: 'Something went wrong...',
);
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) { VoteStatus.failureKarmaBelowThreshold) {
showSnackBar( context.showSnackBar(
context,
content: "You can't downvote because" content: "You can't downvote because"
' you are karmaly broke.', ' you are karmaly broke.',
); );
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureNotLoggedIn) { VoteStatus.failureNotLoggedIn) {
showSnackBar( context.showSnackBar(
context,
content: 'Not logged in, no voting! (;O´)o', content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped, action: onLoginTapped,
label: 'Log in', label: 'Log in',
); );
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureBeHumble) { VoteStatus.failureBeHumble) {
showSnackBar( context.showSnackBar(
context,
content: 'No voting on your own post! (;O´)o', content: 'No voting on your own post! (;O´)o',
); );
} }
@ -153,26 +147,4 @@ class PollView extends StatelessWidget {
}, },
); );
} }
void showSnackBar(
BuildContext context, {
required String content,
VoidCallback? action,
String? label,
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: Palette.deepOrange,
content: Text(content),
action: action != null && label != null
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
);
}
} }

View File

@ -336,9 +336,10 @@ class _ReplyBoxState extends State<ReplyBox> {
child: SingleChildScrollView( child: SingleChildScrollView(
child: SelectableLinkify( child: SelectableLinkify(
scrollPhysics: const NeverScrollableScrollPhysics(), scrollPhysics: const NeverScrollableScrollPhysics(),
linkStyle: TextStyle( textScaleFactor:
fontSize: MediaQuery.of(context).textScaleFactor * MediaQuery.of(context).textScaleFactor,
TextDimens.pt15, linkStyle: const TextStyle(
fontSize: TextDimens.pt15,
color: Palette.orange, color: Palette.orange,
), ),
onOpen: (LinkableElement link) => onOpen: (LinkableElement link) =>

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
@ -384,193 +383,6 @@ class _ProfileScreenState extends State<ProfileScreen>
}); });
} }
void onLoginTapped() {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return BlocConsumer<AuthBloc, AuthState>(
listener: (BuildContext context, AuthState state) {
if (state.isLoggedIn) {
Navigator.pop(context);
showSnackBar(content: 'Logged in successfully!');
}
},
builder: (BuildContext context, AuthState state) {
return SimpleDialog(
children: <Widget>[
if (state.status == AuthStatus.loading)
const SizedBox(
height: Dimens.pt36,
width: Dimens.pt36,
child: Center(
child: CircularProgressIndicator(
color: Palette.orange,
),
),
)
else if (!state.isLoggedIn) ...<Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt18,
),
child: TextField(
controller: usernameController,
cursorColor: Palette.orange,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'Username',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Palette.orange),
),
),
),
),
const SizedBox(
height: Dimens.pt16,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt18,
),
child: TextField(
controller: passwordController,
cursorColor: Palette.orange,
obscureText: true,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'Password',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Palette.orange),
),
),
),
),
const SizedBox(
height: Dimens.pt16,
),
if (state.status == AuthStatus.failure)
const Padding(
padding: EdgeInsets.only(
left: Dimens.pt18,
),
child: Text(
'Something went wrong...',
style: TextStyle(
color: Palette.grey,
fontSize: TextDimens.pt12,
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(
state.agreedToEULA
? Icons.check_box
: Icons.check_box_outline_blank,
color: state.agreedToEULA
? Palette.deepOrange
: Palette.grey,
),
onPressed: () => context
.read<AuthBloc>()
.add(AuthToggleAgreeToEULA()),
),
Text.rich(
TextSpan(
children: <InlineSpan>[
const TextSpan(
text: 'I agree to ',
style: TextStyle(
fontWeight: FontWeight.w500,
),
),
WidgetSpan(
child: Transform.translate(
offset: const Offset(0, 1),
child: TapDownWrapper(
onTap: () => LinkUtil.launch(
Constants.endUserAgreementLink,
),
child: const Text(
'End User Agreement',
style: TextStyle(
color: Palette.deepOrange,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
)
],
),
Padding(
padding: const EdgeInsets.only(
right: Dimens.pt12,
),
child: ButtonBar(
children: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
context.read<AuthBloc>().add(AuthInitialize());
},
child: const Text(
'Cancel',
),
),
ElevatedButton(
onPressed: () {
if (state.agreedToEULA) {
final String username = usernameController.text;
final String password = passwordController.text;
if (username.isNotEmpty && password.isNotEmpty) {
context.read<AuthBloc>().add(
AuthLogin(
username: username,
password: password,
),
);
}
}
},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
state.agreedToEULA
? Palette.deepOrange
: Palette.grey,
),
),
child: const Text(
'Log in',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Palette.white,
),
),
),
],
),
),
],
],
);
},
);
},
);
}
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/bloc_builder_3.dart'; import 'package:hacki/screens/widgets/bloc_builder_3.dart';
import 'package:hacki/screens/widgets/centered_text.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -40,6 +41,8 @@ class CommentTile extends StatelessWidget {
final void Function(String) onStoryLinkTapped; final void Function(String) onStoryLinkTapped;
final FetchMode fetchMode; final FetchMode fetchMode;
static final Map<int, Color> _colors = <int, Color>{};
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<CollapseCubit>( return BlocProvider<CollapseCubit>(
@ -157,136 +160,45 @@ class CommentTile extends StatelessWidget {
], ],
), ),
), ),
if (actionable && state.collapsed) AnimatedSize(
Center( duration: const Duration(milliseconds: 200),
child: Padding( child: Column(
padding: const EdgeInsets.only( crossAxisAlignment: CrossAxisAlignment.start,
bottom: Dimens.pt12, children: <Widget>[
), if (actionable && state.collapsed)
child: Text( CenteredText(
'collapsed ' text:
'(${state.collapsedCount + 1})', '''collapsed (${state.collapsedCount + 1})''',
style: const TextStyle(
color: Palette.orangeAccent, color: Palette.orangeAccent,
), )
), else if (comment.deleted)
), const CenteredText.deleted()
) else if (comment.dead)
else if (comment.deleted) const CenteredText.dead()
const Center( else if (blocklistState.blocklist
child: Padding( .contains(comment.by))
padding: EdgeInsets.only( const CenteredText.blocked()
bottom: Dimens.pt12, else
), Padding(
child: Text( padding: const EdgeInsets.only(
'deleted', left: Dimens.pt8,
style: TextStyle( right: Dimens.pt8,
color: Palette.grey, top: Dimens.pt6,
), bottom: Dimens.pt12,
),
),
)
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),
), ),
child: SizedBox(
width: double.infinity,
child: _CommentText(
key: ValueKey<int>(comment.id),
comment: comment,
onLinkTapped: _onLinkTapped,
),
),
),
],
), ),
if (!state.collapsed && ),
fetchMode == FetchMode.lazy && if (_shouldShowLoadButton(context))
comment.kids.isNotEmpty &&
!context
.read<CommentsCubit>()
.state
.commentIds
.contains(comment.kids.first) &&
!context
.read<CommentsCubit>()
.state
.onlyShowTargetComment)
Center( Center(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -376,8 +288,6 @@ class CommentTile extends StatelessWidget {
); );
} }
static final Map<int, Color> _colors = <int, Color>{};
Color _getColor(int level) { Color _getColor(int level) {
final int initialLevel = level; final int initialLevel = level;
if (_colors[initialLevel] != null) return _colors[initialLevel]!; if (_colors[initialLevel] != null) return _colors[initialLevel]!;
@ -406,6 +316,68 @@ class CommentTile extends StatelessWidget {
return color; 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),
);
} else {
return SelectableLinkify(
text: comment.text,
style: style,
linkStyle: linkStyle,
onOpen: onLinkTapped,
onTap: () => onTextTapped(context),
);
}
}
void onTextTapped(BuildContext context) { void onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) { if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();

View File

@ -108,10 +108,10 @@ class _CountDownReminderState extends State<CountdownReminder>
if (state.storyId != null) { if (state.storyId != null) {
locator locator
.get<StoriesRepository>() .get<StoriesRepository>()
.fetchStoryBy(state.storyId!) .fetchStory(id: state.storyId!)
.then((Story? story) { .then((Story? story) {
if (story == null) { if (story == null) {
showSnackBar(content: 'Something went wrong...'); showErrorSnackBar();
return; return;
} }
final ItemScreenArgs args = ItemScreenArgs(item: story); final ItemScreenArgs args = ItemScreenArgs(item: story);

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/link_preview/web_analyzer.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
class OfflineBanner extends StatelessWidget { class OfflineBanner extends StatelessWidget {

View File

@ -1,9 +1,11 @@
export 'bloc_builder_3.dart'; export 'bloc_builder_3.dart';
export 'centered_text.dart';
export 'circle_tab_indicator.dart'; export 'circle_tab_indicator.dart';
export 'comment_tile.dart'; export 'comment_tile.dart';
export 'countdown_reminder.dart'; export 'countdown_reminder.dart';
export 'custom_chip.dart'; export 'custom_chip.dart';
export 'custom_circular_progress_indicator.dart'; export 'custom_circular_progress_indicator.dart';
export 'custom_described_feature_overlay.dart';
export 'custom_tab_bar.dart'; export 'custom_tab_bar.dart';
export 'items_list_view.dart'; export 'items_list_view.dart';
export 'link_preview/link_preview.dart'; export 'link_preview/link_preview.dart';

View File

@ -50,7 +50,7 @@ abstract class Fetcher {
Comment? newReply; Comment? newReply;
await storiesRepository await storiesRepository
.fetchSubmitted(of: username) .fetchSubmitted(userId: username)
.then((List<int>? submittedItems) async { .then((List<int>? submittedItems) async {
if (submittedItems != null) { if (submittedItems != null) {
final List<int> subscribedItems = submittedItems.sublist( final List<int> subscribedItems = submittedItems.sublist(
@ -59,9 +59,7 @@ abstract class Fetcher {
); );
for (final int id in subscribedItems) { for (final int id in subscribedItems) {
await storiesRepository await storiesRepository.fetchRawItem(id: id).then((Item? item) async {
.fetchRawItemBy(id: id)
.then((Item? item) async {
final List<int> kids = item?.kids ?? <int>[]; final List<int> kids = item?.kids ?? <int>[];
final List<int> previousKids = final List<int> previousKids =
(await sembastRepository.kids(of: id)) ?? <int>[]; (await sembastRepository.kids(of: id)) ?? <int>[];
@ -76,7 +74,7 @@ abstract class Fetcher {
if (unreadIds.contains(newCommentId)) continue; if (unreadIds.contains(newCommentId)) continue;
await storiesRepository await storiesRepository
.fetchRawCommentBy(id: newCommentId) .fetchRawComment(id: newCommentId)
.then((Comment? comment) async { .then((Comment? comment) async {
final bool hasPushedBefore = final bool hasPushedBefore =
await preferenceRepository.hasPushed(newReply!.id); await preferenceRepository.hasPushed(newReply!.id);

View File

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

View File

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

View File

@ -14,7 +14,7 @@ abstract class LogUtil {
colors: false, colors: false,
); );
static LogOutput getLogOutput(File outputFile) => MultiOutput( static LogOutput logOutput(File outputFile) => MultiOutput(
<LogOutput>[ <LogOutput>[
ConsoleOutput(), ConsoleOutput(),
CustomFileOutput( CustomFileOutput(
@ -43,7 +43,7 @@ abstract class LogUtil {
final Uint8List fileContent = await currentSessionLog.readAsBytes(); final Uint8List fileContent = await currentSessionLog.readAsBytes();
await previousSessionLog.writeAsString( await previousSessionLog.writeAsString(
'Current session logs:', 'Current session logs:\n',
mode: FileMode.append, mode: FileMode.append,
); );
return previousSessionLog.writeAsBytes( return previousSessionLog.writeAsBytes(

View File

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

View File

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

View File

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