mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
c375def289 | |||
3469543c7b | |||
ab755581fd | |||
6b75eb8549 | |||
36ded8a8e3 | |||
582ac7b0be | |||
e5e3391785 | |||
9159fe0fe1 | |||
7c51bad35e | |||
6836138d11 | |||
2f71964277 | |||
c24c5c1b7a | |||
755b112382 | |||
d44b64d249 | |||
35ed917e66 | |||
15b75ef37c | |||
f39408fbcc | |||
ca2f063297 | |||
1ad231adbb | |||
60b09fd81e |
BIN
assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
Normal file
BIN
assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
Normal file
BIN
assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
BIN
assets/fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
BIN
assets/fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
Normal file
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
Normal file
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
Normal file
Binary file not shown.
5
fastlane/metadata/android/en-US/changelogs/91.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/91.txt
Normal file
@ -0,0 +1,5 @@
|
||||
- Customization of tab bar.
|
||||
- Option to enable swipe gesture for switching between tabs.
|
||||
- Access to action menu from home screen.
|
||||
- Access to Wikipedia and Wiktionary from text selection toolbar.
|
||||
- Quotes and emphasis rendering.
|
5
fastlane/metadata/android/en-US/changelogs/92.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/92.txt
Normal file
@ -0,0 +1,5 @@
|
||||
- Customization of tab bar.
|
||||
- Option to enable swipe gesture for switching between tabs.
|
||||
- Access to action menu from home screen.
|
||||
- Access to Wikipedia and Wiktionary from text selection toolbar.
|
||||
- Quotes and emphasis rendering.
|
@ -137,7 +137,7 @@ SPEC CHECKSUMS:
|
||||
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
|
||||
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2
|
||||
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
|
||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||
|
@ -18,6 +18,8 @@ import flutter_local_notifications
|
||||
center.delegate = self
|
||||
|
||||
WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!)
|
||||
|
||||
WorkmanagerPlugin.registerTask(withIdentifier: "workmanager.background.task")
|
||||
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
|
@ -41,20 +41,20 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
await _authRepository.loggedIn.then((bool loggedIn) async {
|
||||
if (loggedIn) {
|
||||
final String? username = await _authRepository.username;
|
||||
final User user =
|
||||
await _storiesRepository.fetchUserBy(userId: username!);
|
||||
final User user = await _storiesRepository.fetchUser(id: username!);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoggedIn: true,
|
||||
user: user,
|
||||
status: AuthStatus.loaded,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AuthStatus.loaded,
|
||||
isLoggedIn: false,
|
||||
status: AuthStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -84,8 +84,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
if (successful) {
|
||||
final User user =
|
||||
await _storiesRepository.fetchUserBy(userId: event.username);
|
||||
final User user = await _storiesRepository.fetchUser(id: event.username);
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user,
|
||||
|
@ -56,15 +56,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
static const int _tabletSmallPageSize = 15;
|
||||
static const int _tabletLargePageSize = 25;
|
||||
|
||||
/// Types of story to be shown in the tab bar.
|
||||
static const Set<StoryType> types = <StoryType>{
|
||||
StoryType.top,
|
||||
StoryType.best,
|
||||
StoryType.latest,
|
||||
StoryType.ask,
|
||||
StoryType.show,
|
||||
};
|
||||
|
||||
Future<void> onInitialize(
|
||||
StoriesInitialize event,
|
||||
Emitter<StoriesState> emit,
|
||||
@ -72,7 +63,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
_streamSubscription ??=
|
||||
_preferenceCubit.stream.listen((PreferenceState event) {
|
||||
final bool isComplexTile = event.complexStoryTileEnabled;
|
||||
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
|
||||
final int pageSize = getPageSize(isComplexTile: isComplexTile);
|
||||
|
||||
if (pageSize != state.currentPageSize) {
|
||||
add(StoriesPageSizeChanged(pageSize: pageSize));
|
||||
@ -80,7 +71,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
});
|
||||
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
|
||||
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
|
||||
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
|
||||
final int pageSize = getPageSize(isComplexTile: isComplexTile);
|
||||
emit(
|
||||
const StoriesState.init().copyWith(
|
||||
offlineReading: hasCachedStories &&
|
||||
@ -92,44 +83,45 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
storiesToBeDownloaded: state.storiesToBeDownloaded,
|
||||
),
|
||||
);
|
||||
for (final StoryType type in types) {
|
||||
await loadStories(of: type, emit: emit);
|
||||
for (final StoryType type in StoryType.values) {
|
||||
await loadStories(type: type, emit: emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadStories({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required Emitter<StoriesState> emit,
|
||||
}) async {
|
||||
if (state.offlineReading) {
|
||||
final List<int> ids = await _offlineRepository.getCachedStoryIds(of: of);
|
||||
final List<int> ids =
|
||||
await _offlineRepository.getCachedStoryIds(type: type);
|
||||
emit(
|
||||
state
|
||||
.copyWithStoryIdsUpdated(of: of, to: ids)
|
||||
.copyWithCurrentPageUpdated(of: of, to: 0),
|
||||
.copyWithStoryIdsUpdated(type: type, to: ids)
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0),
|
||||
);
|
||||
_offlineRepository
|
||||
.getCachedStoriesStream(
|
||||
ids: ids.sublist(0, min(ids.length, state.currentPageSize)),
|
||||
)
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: of));
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: of));
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
} else {
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(of: of);
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
|
||||
emit(
|
||||
state
|
||||
.copyWithStoryIdsUpdated(of: of, to: ids)
|
||||
.copyWithCurrentPageUpdated(of: of, to: 0),
|
||||
.copyWithStoryIdsUpdated(type: type, to: ids)
|
||||
.copyWithCurrentPageUpdated(type: type, to: 0),
|
||||
);
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
|
||||
.listen((Story story) {
|
||||
add(StoryLoaded(story: story, type: of));
|
||||
add(StoryLoaded(story: story, type: type));
|
||||
}).onDone(() {
|
||||
add(StoriesLoaded(type: of));
|
||||
add(StoriesLoaded(type: type));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -140,7 +132,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
) async {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
type: event.type,
|
||||
to: StoriesStatus.loading,
|
||||
),
|
||||
);
|
||||
@ -148,27 +140,29 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
if (state.offlineReading) {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
type: event.type,
|
||||
to: StoriesStatus.loaded,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWithRefreshed(of: event.type));
|
||||
await loadStories(of: event.type, emit: emit);
|
||||
emit(state.copyWithRefreshed(type: event.type));
|
||||
await loadStories(type: event.type, emit: emit);
|
||||
}
|
||||
}
|
||||
|
||||
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
type: event.type,
|
||||
to: StoriesStatus.loading,
|
||||
),
|
||||
);
|
||||
|
||||
final int currentPage = state.currentPageByType[event.type]!;
|
||||
final int len = state.storyIdsByType[event.type]!.length;
|
||||
emit(state.copyWithCurrentPageUpdated(of: event.type, to: currentPage + 1));
|
||||
emit(
|
||||
state.copyWithCurrentPageUpdated(type: event.type, to: currentPage + 1),
|
||||
);
|
||||
final int currentPageSize = state.currentPageSize;
|
||||
final int lower = currentPageSize * (currentPage + 1);
|
||||
int upper = currentPageSize + lower;
|
||||
@ -218,7 +212,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
} else {
|
||||
emit(
|
||||
state.copyWithStatusUpdated(
|
||||
of: event.type,
|
||||
type: event.type,
|
||||
to: StoriesStatus.loaded,
|
||||
),
|
||||
);
|
||||
@ -232,7 +226,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
|
||||
emit(
|
||||
state.copyWithStoryAdded(
|
||||
of: event.type,
|
||||
type: event.type,
|
||||
story: event.story,
|
||||
hasRead: hasRead,
|
||||
),
|
||||
@ -240,7 +234,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
|
||||
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
|
||||
emit(state.copyWithStatusUpdated(of: event.type, to: StoriesStatus.loaded));
|
||||
emit(
|
||||
state.copyWithStatusUpdated(type: event.type, to: StoriesStatus.loaded),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onDownload(
|
||||
@ -258,12 +254,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
await _offlineRepository.deleteAllComments();
|
||||
|
||||
final Set<int> prioritizedIds = <int>{};
|
||||
final List<StoryType> prioritizedTypes = <StoryType>[...types]
|
||||
|
||||
/// Prioritizing all types of stories except StoryType.latest since
|
||||
/// new stories tend to have less or no comment at all.
|
||||
final List<StoryType> prioritizedTypes = <StoryType>[...StoryType.values]
|
||||
..remove(StoryType.latest);
|
||||
|
||||
for (final StoryType type in prioritizedTypes) {
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(of: type);
|
||||
await _offlineRepository.cacheStoryIds(of: type, ids: ids);
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(type: type);
|
||||
await _offlineRepository.cacheStoryIds(type: type, ids: ids);
|
||||
prioritizedIds.addAll(ids);
|
||||
}
|
||||
|
||||
@ -283,9 +282,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
|
||||
final Set<int> latestIds = <int>{};
|
||||
final List<int> ids = await _storiesRepository.fetchStoryIds(
|
||||
of: StoryType.latest,
|
||||
type: StoryType.latest,
|
||||
);
|
||||
await _offlineRepository.cacheStoryIds(of: StoryType.latest, ids: ids);
|
||||
await _offlineRepository.cacheStoryIds(type: StoryType.latest, ids: ids);
|
||||
latestIds.addAll(ids);
|
||||
|
||||
await fetchAndCacheStories(
|
||||
@ -311,10 +310,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
downloadStatus: StoriesDownloadStatus.canceled,
|
||||
),
|
||||
);
|
||||
|
||||
await _offlineRepository.deleteAllStoryIds();
|
||||
await _offlineRepository.deleteAllStories();
|
||||
await _offlineRepository.deleteAllComments();
|
||||
}
|
||||
|
||||
Future<void> fetchAndCacheStories(
|
||||
@ -322,11 +317,25 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
required bool includingWebPage,
|
||||
required bool isPrioritized,
|
||||
}) async {
|
||||
final List<StreamSubscription<Comment>> downloadStreams =
|
||||
<StreamSubscription<Comment>>[];
|
||||
for (final int id in ids) {
|
||||
if (state.downloadStatus == StoriesDownloadStatus.canceled) break;
|
||||
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
|
||||
_logger.d('aborting downloading');
|
||||
|
||||
for (final StreamSubscription<Comment> stream in downloadStreams) {
|
||||
await stream.cancel();
|
||||
}
|
||||
|
||||
_logger.d('deleting downloaded contents');
|
||||
await _offlineRepository.deleteAllStoryIds();
|
||||
await _offlineRepository.deleteAllStories();
|
||||
await _offlineRepository.deleteAllComments();
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.d('fetching story $id');
|
||||
final Story? story = await _storiesRepository.fetchStoryBy(id);
|
||||
final Story? story = await _storiesRepository.fetchStory(id: id);
|
||||
|
||||
if (story == null) {
|
||||
if (isPrioritized) {
|
||||
@ -349,17 +358,37 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
await _offlineRepository.cacheUrl(url: story.url);
|
||||
}
|
||||
|
||||
_storiesRepository
|
||||
/// Not awaiting the completion of comments stream because otherwise
|
||||
/// it's going to take forever to finish downloading all the stories
|
||||
/// since we need to make a single http call for each comment.
|
||||
///
|
||||
/// In other words, we are prioritizing the story itself instead of
|
||||
/// the comments in the story.
|
||||
late final StreamSubscription<Comment>? downloadStream;
|
||||
downloadStream = _storiesRepository
|
||||
.fetchAllChildrenComments(ids: story.kids)
|
||||
.whereType<Comment>()
|
||||
.listen(
|
||||
(Comment comment) {
|
||||
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
|
||||
_logger.d('aborting downloading from comments stream');
|
||||
downloadStream?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.d('fetched comment ${comment.id}');
|
||||
unawaited(
|
||||
_offlineRepository.cacheComment(comment: comment),
|
||||
);
|
||||
},
|
||||
).onDone(() => add(StoryDownloaded(skipped: false)));
|
||||
)..onDone(() {
|
||||
_logger.d(
|
||||
'''finished downloading story ${story.id} with ${story.descendants} comments''',
|
||||
);
|
||||
add(StoryDownloaded(skipped: false));
|
||||
});
|
||||
|
||||
downloadStreams.add(downloadStream);
|
||||
}
|
||||
}
|
||||
|
||||
@ -443,7 +472,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
|
||||
bool hasRead(Story story) => state.readStoriesIds.contains(story.id);
|
||||
|
||||
int _getPageSize({required bool isComplexTile}) {
|
||||
int getPageSize({required bool isComplexTile}) {
|
||||
int pageSize = isComplexTile ? _smallPageSize : _largePageSize;
|
||||
|
||||
if (deviceScreenType != DeviceScreenType.mobile) {
|
||||
|
@ -103,13 +103,13 @@ class StoriesState extends Equatable {
|
||||
}
|
||||
|
||||
StoriesState copyWithStoryAdded({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required Story story,
|
||||
required bool hasRead,
|
||||
}) {
|
||||
final Map<StoryType, List<Story>> newMap =
|
||||
Map<StoryType, List<Story>>.from(storiesByType);
|
||||
newMap[of] = List<Story>.from(newMap[of]!)..add(story);
|
||||
newMap[type] = List<Story>.from(newMap[type]!)..add(story);
|
||||
return copyWith(
|
||||
storiesByType: newMap,
|
||||
readStoriesIds: <int>{
|
||||
@ -120,54 +120,54 @@ class StoriesState extends Equatable {
|
||||
}
|
||||
|
||||
StoriesState copyWithStoryIdsUpdated({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required List<int> to,
|
||||
}) {
|
||||
final Map<StoryType, List<int>> newMap =
|
||||
Map<StoryType, List<int>>.from(storyIdsByType);
|
||||
newMap[of] = to;
|
||||
newMap[type] = to;
|
||||
return copyWith(
|
||||
storyIdsByType: newMap,
|
||||
);
|
||||
}
|
||||
|
||||
StoriesState copyWithStatusUpdated({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required StoriesStatus to,
|
||||
}) {
|
||||
final Map<StoryType, StoriesStatus> newMap =
|
||||
Map<StoryType, StoriesStatus>.from(statusByType);
|
||||
newMap[of] = to;
|
||||
newMap[type] = to;
|
||||
return copyWith(
|
||||
statusByType: newMap,
|
||||
);
|
||||
}
|
||||
|
||||
StoriesState copyWithCurrentPageUpdated({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required int to,
|
||||
}) {
|
||||
final Map<StoryType, int> newMap =
|
||||
Map<StoryType, int>.from(currentPageByType);
|
||||
newMap[of] = to;
|
||||
newMap[type] = to;
|
||||
return copyWith(
|
||||
currentPageByType: newMap,
|
||||
);
|
||||
}
|
||||
|
||||
StoriesState copyWithRefreshed({required StoryType of}) {
|
||||
StoriesState copyWithRefreshed({required StoryType type}) {
|
||||
final Map<StoryType, List<Story>> newStoriesMap =
|
||||
Map<StoryType, List<Story>>.from(storiesByType);
|
||||
newStoriesMap[of] = <Story>[];
|
||||
newStoriesMap[type] = <Story>[];
|
||||
final Map<StoryType, List<int>> newStoryIdsMap =
|
||||
Map<StoryType, List<int>>.from(storyIdsByType);
|
||||
newStoryIdsMap[of] = <int>[];
|
||||
newStoryIdsMap[type] = <int>[];
|
||||
final Map<StoryType, StoriesStatus> newStatusMap =
|
||||
Map<StoryType, StoriesStatus>.from(statusByType);
|
||||
newStatusMap[of] = StoriesStatus.loading;
|
||||
newStatusMap[type] = StoriesStatus.loading;
|
||||
final Map<StoryType, int> newCurrentPageMap =
|
||||
Map<StoryType, int>.from(currentPageByType);
|
||||
newCurrentPageMap[of] = 0;
|
||||
newCurrentPageMap[type] = 0;
|
||||
return copyWith(
|
||||
storiesByType: newStoriesMap,
|
||||
storyIdsByType: newStoryIdsMap,
|
||||
|
@ -16,6 +16,8 @@ abstract class Constants {
|
||||
'https://news.ycombinator.com/newsguidelines.html';
|
||||
static const String githubIssueLink =
|
||||
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
|
||||
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
|
||||
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
|
||||
static const String supportEmail = 'georgefung98@gmail.com';
|
||||
|
||||
static const String _imagePath = 'assets/images';
|
||||
@ -57,10 +59,18 @@ abstract class Constants {
|
||||
'(ㆆ_ㆆ)',
|
||||
].pickRandomly()!;
|
||||
|
||||
static final String magicWord = <String>[
|
||||
'to be over the rainbow!',
|
||||
'to infinity and beyond!',
|
||||
'to see the future.',
|
||||
].pickRandomly()!;
|
||||
|
||||
static final String errorMessage = 'Something went wrong...$sadFace';
|
||||
static final String loginErrorMessage =
|
||||
'''Failed to log in $sadFace, this could happen if your account requires a CAPTCHA, please try logging in inside a browser to see if this is the case, if so, you may try logging in here again later after CAPTCHA is no longer needed.''';
|
||||
}
|
||||
|
||||
abstract class RegExpConstants {
|
||||
static const String linkSuffix = r'(\)|])(.)*$';
|
||||
static const String linkSuffix = r'(\)|]|,|\*)(.)*$';
|
||||
static const String number = '[0-9]+';
|
||||
}
|
||||
|
@ -3,15 +3,18 @@ import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/utils/linkifier_util.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'comments_state.dart';
|
||||
|
||||
@ -73,12 +76,12 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
Future<void> init({
|
||||
bool onlyShowTargetComment = false,
|
||||
bool useCommentCache = false,
|
||||
List<Comment>? targetParents,
|
||||
List<Comment>? targetAncestors,
|
||||
}) async {
|
||||
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) {
|
||||
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
comments: targetParents,
|
||||
comments: targetAncestors,
|
||||
onlyShowTargetComment: true,
|
||||
status: CommentsStatus.allLoaded,
|
||||
),
|
||||
@ -86,9 +89,11 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: targetParents!.last.kids,
|
||||
level: targetParents.last.level + 1,
|
||||
ids: targetAncestors!.last.kids,
|
||||
level: targetAncestors.last.level + 1,
|
||||
)
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
|
||||
@ -106,38 +111,38 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final Item item = state.item;
|
||||
final Item updatedItem = state.offlineReading
|
||||
? item
|
||||
: await _storiesRepository.fetchItemBy(id: item.id) ?? item;
|
||||
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
|
||||
item;
|
||||
final List<int> kids = sortKids(updatedItem.kids);
|
||||
|
||||
emit(state.copyWith(item: updatedItem));
|
||||
|
||||
late final Stream<Comment> commentStream;
|
||||
|
||||
if (state.offlineReading) {
|
||||
_streamSubscription = _offlineRepository
|
||||
.getCachedCommentsStream(ids: kids)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
|
||||
} else {
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
break;
|
||||
case FetchMode.eager:
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_streamSubscription = commentStream
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
@ -173,25 +178,26 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
final Item item = state.item;
|
||||
final Item updatedItem =
|
||||
await _storiesRepository.fetchItemBy(id: item.id) ?? item;
|
||||
await _storiesRepository.fetchItem(id: item.id) ?? item;
|
||||
final List<int> kids = sortKids(updatedItem.kids);
|
||||
|
||||
late final Stream<Comment> commentStream;
|
||||
if (state.fetchMode == FetchMode.lazy) {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(
|
||||
ids: kids,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
);
|
||||
} else {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
);
|
||||
}
|
||||
|
||||
_streamSubscription = commentStream
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
item: updatedItem,
|
||||
@ -227,23 +233,18 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final StreamSubscription<Comment> streamSubscription =
|
||||
_storiesRepository
|
||||
.fetchCommentsStream(ids: comment.kids)
|
||||
.asyncMap(_toBuildableComment)
|
||||
.whereNotNull()
|
||||
.listen((Comment cmt) {
|
||||
_collapseCache.addKid(cmt.id, to: cmt.parent);
|
||||
_commentCache.cacheComment(cmt);
|
||||
_sembastRepository.cacheComment(cmt);
|
||||
|
||||
final List<LinkifyElement> elements = _linkify(
|
||||
cmt.text,
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(cmt, elements: elements);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
comments: <Comment>[...state.comments]..insert(
|
||||
state.comments.indexOf(comment) + offset + 1,
|
||||
buildableComment.copyWith(level: level),
|
||||
cmt.copyWith(level: level),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -273,8 +274,9 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
Future<void> loadParentThread() async {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
|
||||
final Story? parent =
|
||||
await _storiesRepository.fetchParentStory(id: state.item.id);
|
||||
final Story? parent = await _storiesRepository
|
||||
.fetchParentStory(id: state.item.id)
|
||||
.then(_toBuildableStory);
|
||||
|
||||
if (parent == null) {
|
||||
return;
|
||||
@ -340,22 +342,15 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onCommentFetched(Comment? comment) {
|
||||
void _onCommentFetched(BuildableComment? comment) {
|
||||
if (comment != null) {
|
||||
_collapseCache.addKid(comment.id, to: comment.parent);
|
||||
_commentCache.cacheComment(comment);
|
||||
_sembastRepository.cacheComment(comment);
|
||||
|
||||
final List<LinkifyElement> elements = _linkify(
|
||||
comment.text,
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(comment, elements: elements);
|
||||
|
||||
final List<Comment> updatedComments = <Comment>[
|
||||
...state.comments,
|
||||
buildableComment
|
||||
comment
|
||||
];
|
||||
|
||||
emit(state.copyWith(comments: updatedComments));
|
||||
@ -387,29 +382,51 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
}
|
||||
}
|
||||
|
||||
static List<LinkifyElement> _linkify(
|
||||
String text, {
|
||||
LinkifyOptions options = const LinkifyOptions(),
|
||||
List<Linkifier> linkifiers = const <Linkifier>[
|
||||
UrlLinkifier(),
|
||||
EmailLinkifier(),
|
||||
],
|
||||
}) {
|
||||
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
|
||||
static Future<Item?> _toBuildable(Item? item) async {
|
||||
if (item == null) return null;
|
||||
|
||||
if (text.isEmpty) {
|
||||
return <LinkifyElement>[];
|
||||
switch (item.runtimeType) {
|
||||
case Comment:
|
||||
return _toBuildableComment(item as Comment);
|
||||
case Story:
|
||||
return _toBuildableStory(item as Story);
|
||||
}
|
||||
|
||||
if (linkifiers.isEmpty) {
|
||||
return list;
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
|
||||
if (comment == null) return null;
|
||||
|
||||
final List<LinkifyElement> elements =
|
||||
await compute<String, List<LinkifyElement>>(
|
||||
LinkifierUtil.linkify,
|
||||
comment.text,
|
||||
);
|
||||
|
||||
final BuildableComment buildableComment =
|
||||
BuildableComment.fromComment(comment, elements: elements);
|
||||
|
||||
return buildableComment;
|
||||
}
|
||||
|
||||
static Future<BuildableStory?> _toBuildableStory(Story? story) async {
|
||||
if (story == null) {
|
||||
return null;
|
||||
} else if (story.text.isEmpty) {
|
||||
return BuildableStory.fromTitleOnlyStory(story);
|
||||
}
|
||||
|
||||
for (final Linkifier linkifier in linkifiers) {
|
||||
list = linkifier.parse(list, options);
|
||||
}
|
||||
final List<LinkifyElement> elements =
|
||||
await compute<String, List<LinkifyElement>>(
|
||||
LinkifierUtil.linkify,
|
||||
story.text,
|
||||
);
|
||||
|
||||
return list;
|
||||
final BuildableStory buildableStory =
|
||||
BuildableStory.fromStory(story, elements: elements);
|
||||
|
||||
return buildableStory;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -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;
|
||||
|
||||
|
@ -28,7 +28,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
final String username = authState.username;
|
||||
|
||||
_storiesRepository
|
||||
.fetchSubmitted(of: username)
|
||||
.fetchSubmitted(userId: username)
|
||||
.then((List<int>? submittedIds) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -94,7 +94,7 @@ class HistoryCubit extends Cubit<HistoryState> {
|
||||
);
|
||||
|
||||
_storiesRepository
|
||||
.fetchSubmitted(of: username)
|
||||
.fetchSubmitted(userId: username)
|
||||
.then((List<int>? submittedIds) {
|
||||
emit(state.copyWith(submittedIds: submittedIds));
|
||||
if (submittedIds != null) {
|
||||
|
@ -81,7 +81,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
for (final int id in commentsToBeLoaded) {
|
||||
Comment? comment = await _sembastRepository.getComment(id: id);
|
||||
comment ??= await _storiesRepository.fetchCommentBy(id: id);
|
||||
comment ??= await _storiesRepository.fetchComment(id: id);
|
||||
if (comment != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -159,7 +159,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
for (final int id in commentsToBeLoaded) {
|
||||
Comment? comment = await _sembastRepository.getComment(id: id);
|
||||
comment ??= await _storiesRepository.fetchCommentBy(id: id);
|
||||
comment ??= await _storiesRepository.fetchComment(id: id);
|
||||
if (comment != null) {
|
||||
emit(state.copyWith(comments: <Comment>[...state.comments, comment]));
|
||||
}
|
||||
@ -184,7 +184,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
Future<void> _fetchReplies() {
|
||||
return _storiesRepository
|
||||
.fetchSubmitted(of: _authBloc.state.username)
|
||||
.fetchSubmitted(userId: _authBloc.state.username)
|
||||
.then((List<int>? submittedItems) async {
|
||||
if (submittedItems != null) {
|
||||
final List<int> subscribedItems = submittedItems.sublist(
|
||||
@ -193,7 +193,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
);
|
||||
|
||||
for (final int id in subscribedItems) {
|
||||
await _storiesRepository.fetchItemBy(id: id).then((Item? item) async {
|
||||
await _storiesRepository.fetchItem(id: id).then((Item? item) async {
|
||||
final List<int> kids = item?.kids ?? <int>[];
|
||||
final List<int> previousKids =
|
||||
(await _sembastRepository.kids(of: id)) ?? <int>[];
|
||||
@ -216,7 +216,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
|
||||
);
|
||||
await _storiesRepository
|
||||
.fetchCommentBy(id: newCommentId)
|
||||
.fetchComment(id: newCommentId)
|
||||
.then((Comment? comment) {
|
||||
if (comment != null && !comment.dead && !comment.deleted) {
|
||||
_sembastRepository
|
||||
|
@ -33,7 +33,7 @@ class PollCubit extends Cubit<PollState> {
|
||||
|
||||
if (pollOptionsIds.isEmpty || refresh) {
|
||||
final Story? updatedStory =
|
||||
await _storiesRepository.fetchStoryBy(_story.id);
|
||||
await _storiesRepository.fetchStory(id: _story.id);
|
||||
|
||||
if (updatedStory != null) {
|
||||
pollOptionsIds = updatedStory.parts;
|
||||
|
@ -96,6 +96,9 @@ class PreferenceState extends Equatable {
|
||||
FontSize get fontSize => FontSize.values
|
||||
.elementAt(preferences.singleWhereType<FontSizePreference>().val);
|
||||
|
||||
Font get font =>
|
||||
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),
|
||||
|
@ -15,19 +15,19 @@ class SearchCubit extends Cubit<SearchState> {
|
||||
|
||||
final SearchRepository _searchRepository;
|
||||
|
||||
StreamSubscription<Story>? streamSubscription;
|
||||
StreamSubscription<Item>? streamSubscription;
|
||||
|
||||
void search(String query) {
|
||||
streamSubscription?.cancel();
|
||||
emit(
|
||||
state.copyWith(
|
||||
results: <Story>[],
|
||||
results: <Item>[],
|
||||
status: SearchStatus.loading,
|
||||
params: state.params.copyWith(query: query, page: 0),
|
||||
),
|
||||
);
|
||||
streamSubscription =
|
||||
_searchRepository.search(params: state.params).listen(_onStoryFetched)
|
||||
_searchRepository.search(params: state.params).listen(_onItemFetched)
|
||||
..onDone(() {
|
||||
emit(state.copyWith(status: SearchStatus.loaded));
|
||||
});
|
||||
@ -43,7 +43,7 @@ class SearchCubit extends Cubit<SearchState> {
|
||||
),
|
||||
);
|
||||
streamSubscription =
|
||||
_searchRepository.search(params: state.params).listen(_onStoryFetched)
|
||||
_searchRepository.search(params: state.params).listen(_onItemFetched)
|
||||
..onDone(() {
|
||||
emit(state.copyWith(status: SearchStatus.loaded));
|
||||
});
|
||||
@ -69,6 +69,8 @@ class SearchCubit extends Cubit<SearchState> {
|
||||
}
|
||||
|
||||
void removeFilter<T extends SearchFilter>() {
|
||||
if (state.params.contains<T>() == false) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
params: state.params.copyWithFilterRemoved<T>(),
|
||||
@ -78,6 +80,16 @@ class SearchCubit extends Cubit<SearchState> {
|
||||
search(state.params.query);
|
||||
}
|
||||
|
||||
void onToggled(TypeTagFilter filter) {
|
||||
if (state.params.contains<TypeTagFilter>() &&
|
||||
state.params.get<TypeTagFilter>() == filter) {
|
||||
removeFilter<TypeTagFilter>();
|
||||
} else {
|
||||
removeFilter<TypeTagFilter>();
|
||||
addFilter<TypeTagFilter>(filter);
|
||||
}
|
||||
}
|
||||
|
||||
void onSortToggled() {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -90,10 +102,44 @@ class SearchCubit extends Cubit<SearchState> {
|
||||
search(state.params.query);
|
||||
}
|
||||
|
||||
void _onStoryFetched(Story story) {
|
||||
void onDateTimeRangeUpdated(DateTime start, DateTime end) {
|
||||
final DateTime updatedStart = start.copyWith(
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
final DateTime updatedEnd = end.copyWith(
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
final DateTime? existingStart =
|
||||
state.params.get<DateTimeRangeFilter>()?.startTime;
|
||||
final DateTime? existingEnd =
|
||||
state.params.get<DateTimeRangeFilter>()?.endTime;
|
||||
|
||||
if (existingStart == updatedStart && existingEnd == updatedEnd) return;
|
||||
|
||||
addFilter(
|
||||
DateTimeRangeFilter(
|
||||
startTime: updatedStart,
|
||||
endTime: updatedEnd,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onPostedByChanged(String? username) {
|
||||
if (username == null) {
|
||||
removeFilter<PostedByFilter>();
|
||||
} else {
|
||||
addFilter(PostedByFilter(author: username));
|
||||
}
|
||||
}
|
||||
|
||||
void _onItemFetched(Item item) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
results: List<Story>.from(state.results)..add(story),
|
||||
results: List<Item>.from(state.results)..add(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -16,15 +16,15 @@ class SearchState extends Equatable {
|
||||
|
||||
SearchState.init()
|
||||
: status = SearchStatus.initial,
|
||||
results = <Story>[],
|
||||
results = <Item>[],
|
||||
params = SearchParams.init();
|
||||
|
||||
final List<Story> results;
|
||||
final List<Item> results;
|
||||
final SearchStatus status;
|
||||
final SearchParams params;
|
||||
|
||||
SearchState copyWith({
|
||||
List<Story>? results,
|
||||
List<Item>? results,
|
||||
SearchStatus? status,
|
||||
SearchParams? params,
|
||||
}) {
|
||||
|
@ -20,20 +20,20 @@ class TimeMachineCubit extends Cubit<TimeMachineState> {
|
||||
final CommentCache _commentCache;
|
||||
|
||||
Future<void> activateTimeMachine(Comment comment) async {
|
||||
emit(state.copyWith(parents: <Comment>[]));
|
||||
emit(state.copyWith(ancestors: <Comment>[]));
|
||||
|
||||
final List<Comment> parents = <Comment>[];
|
||||
final List<Comment> ancestors = <Comment>[];
|
||||
Comment? parent = _commentCache.getComment(comment.parent);
|
||||
parent ??= await _sembastRepository.getCachedComment(id: comment.parent);
|
||||
|
||||
while (parent != null) {
|
||||
parents.insert(0, parent);
|
||||
ancestors.insert(0, parent);
|
||||
|
||||
final int parentId = parent.parent;
|
||||
parent = _commentCache.getComment(parentId);
|
||||
parent ??= await _sembastRepository.getCachedComment(id: parentId);
|
||||
}
|
||||
|
||||
emit(state.copyWith(parents: parents));
|
||||
emit(state.copyWith(ancestors: ancestors));
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,18 @@
|
||||
part of 'time_machine_cubit.dart';
|
||||
|
||||
class TimeMachineState extends Equatable {
|
||||
const TimeMachineState({required this.parents});
|
||||
const TimeMachineState({required this.ancestors});
|
||||
|
||||
TimeMachineState.init() : parents = <Comment>[];
|
||||
TimeMachineState.init() : ancestors = <Comment>[];
|
||||
|
||||
final List<Comment> parents;
|
||||
final List<Comment> ancestors;
|
||||
|
||||
TimeMachineState copyWith({
|
||||
List<Comment>? parents,
|
||||
List<Comment>? ancestors,
|
||||
}) {
|
||||
return TimeMachineState(parents: parents ?? this.parents);
|
||||
return TimeMachineState(ancestors: ancestors ?? this.ancestors);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[parents];
|
||||
List<Object?> get props => <Object?>[ancestors];
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ class UserCubit extends Cubit<UserState> {
|
||||
|
||||
void init({required String userId}) {
|
||||
emit(state.copyWith(status: UserStatus.loading));
|
||||
_storiesRepository.fetchUserBy(userId: userId).then((User user) {
|
||||
_storiesRepository.fetchUser(id: userId).then((User user) {
|
||||
emit(state.copyWith(user: user, status: UserStatus.loaded));
|
||||
}).onError((_, __) {
|
||||
emit(state.copyWith(status: UserStatus.failure));
|
||||
|
@ -2,17 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/item/models/models.dart';
|
||||
import 'package:hacki/screens/item/widgets/widgets.dart';
|
||||
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
extension StateExtension on State {
|
||||
@ -59,11 +56,11 @@ extension StateExtension on State {
|
||||
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
|
||||
showModalBottomSheet<MenuAction>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext context) {
|
||||
return MorePopupMenu(
|
||||
item: item,
|
||||
isBlocked: isBlocked,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onLoginTapped: onLoginTapped,
|
||||
);
|
||||
},
|
||||
@ -74,6 +71,9 @@ extension StateExtension on State {
|
||||
break;
|
||||
case MenuAction.downvote:
|
||||
break;
|
||||
case MenuAction.fav:
|
||||
onFavTapped(item);
|
||||
break;
|
||||
case MenuAction.share:
|
||||
onShareTapped(item, rect);
|
||||
break;
|
||||
@ -90,24 +90,13 @@ extension StateExtension on State {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> onStoryLinkTapped(String link) async {
|
||||
final int? id = link.itemId;
|
||||
if (id != null) {
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchItemBy(id: id)
|
||||
.then((Item? item) {
|
||||
if (mounted) {
|
||||
if (item != null) {
|
||||
HackiApp.navigatorKey.currentState!.pushNamed(
|
||||
ItemScreen.routeName,
|
||||
arguments: ItemScreenArgs(item: item),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
void onFavTapped(Item item) {
|
||||
final FavCubit favCubit = context.read<FavCubit>();
|
||||
final bool isFav = favCubit.state.favIds.contains(item.id);
|
||||
if (isFav) {
|
||||
favCubit.removeFav(item.id);
|
||||
} else {
|
||||
LinkUtil.launch(link);
|
||||
favCubit.addFav(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -231,17 +220,11 @@ extension StateExtension on State {
|
||||
}
|
||||
|
||||
void onLoginTapped() {
|
||||
final TextEditingController usernameController = TextEditingController();
|
||||
final TextEditingController passwordController = TextEditingController();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return LoginDialog(
|
||||
usernameController: usernameController,
|
||||
passwordController: passwordController,
|
||||
showSnackBar: showSnackBar,
|
||||
);
|
||||
return const LoginDialog();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
extension WidgetModifier on Widget {
|
||||
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
|
||||
@ -7,4 +11,62 @@ extension WidgetModifier on Widget {
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
Widget contextMenuBuilder(
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState, {
|
||||
required Item item,
|
||||
}) {
|
||||
final int start = editableTextState.textEditingValue.selection.base.offset;
|
||||
final int end = editableTextState.textEditingValue.selection.end;
|
||||
|
||||
final List<ContextMenuButtonItem> items = <ContextMenuButtonItem>[
|
||||
...editableTextState.contextMenuButtonItems,
|
||||
];
|
||||
|
||||
if (start != -1 && end != -1) {
|
||||
String selectedText = item.text.substring(start, end);
|
||||
|
||||
if (item is Buildable) {
|
||||
final Iterable<EmphasisElement> emphasisElements =
|
||||
(item as Buildable).elements.whereType<EmphasisElement>();
|
||||
|
||||
int count = 1;
|
||||
while (selectedText.contains(' ') && count <= emphasisElements.length) {
|
||||
final int s = (start + count * 2).clamp(0, item.text.length);
|
||||
final int e = (end + count * 2).clamp(0, item.text.length);
|
||||
selectedText = item.text.substring(s, e);
|
||||
count++;
|
||||
}
|
||||
|
||||
count = 1;
|
||||
while (selectedText.contains(' ') && count <= emphasisElements.length) {
|
||||
final int s = (start - count * 2).clamp(0, item.text.length);
|
||||
final int e = (end - count * 2).clamp(0, item.text.length);
|
||||
selectedText = item.text.substring(s, e);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
items.addAll(<ContextMenuButtonItem>[
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'''${Constants.wikipediaLink}$selectedText''',
|
||||
),
|
||||
label: 'Wikipedia',
|
||||
),
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'''${Constants.wiktionaryLink}$selectedText''',
|
||||
),
|
||||
label: 'Wiktionary',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return AdaptiveTextSelectionToolbar.buttonItems(
|
||||
anchors: editableTextState.contextMenuAnchors,
|
||||
buttonItems: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -128,6 +128,9 @@ Future<void> main({bool testing = false}) async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
final bool trueDarkMode =
|
||||
prefs.getBool(const TrueDarkModePreference().key) ?? false;
|
||||
final Font font = Font.values.elementAt(
|
||||
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
|
||||
);
|
||||
|
||||
Bloc.observer = CustomBlocObserver();
|
||||
|
||||
@ -137,6 +140,7 @@ Future<void> main({bool testing = false}) async {
|
||||
HackiApp(
|
||||
savedThemeMode: savedThemeMode,
|
||||
trueDarkMode: trueDarkMode,
|
||||
font: font,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -146,9 +150,11 @@ class HackiApp extends StatelessWidget {
|
||||
super.key,
|
||||
this.savedThemeMode,
|
||||
required this.trueDarkMode,
|
||||
required this.font,
|
||||
});
|
||||
|
||||
final AdaptiveThemeMode? savedThemeMode;
|
||||
final Font font;
|
||||
final bool trueDarkMode;
|
||||
|
||||
static final GlobalKey<NavigatorState> navigatorKey =
|
||||
@ -227,11 +233,13 @@ class HackiApp extends StatelessWidget {
|
||||
child: AdaptiveTheme(
|
||||
light: ThemeData(
|
||||
primarySwatch: Palette.orange,
|
||||
fontFamily: font.name,
|
||||
),
|
||||
dark: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: trueDarkMode ? Palette.black : null,
|
||||
fontFamily: font.name,
|
||||
),
|
||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||
builder: (ThemeData theme, ThemeData darkTheme) {
|
||||
@ -239,6 +247,7 @@ class HackiApp extends StatelessWidget {
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Palette.orange,
|
||||
canvasColor: Palette.black,
|
||||
fontFamily: font.name,
|
||||
);
|
||||
return FutureBuilder<AdaptiveThemeMode?>(
|
||||
future: AdaptiveTheme.getThemeMode(),
|
||||
|
10
lib/models/font.dart
Normal file
10
lib/models/font.dart
Normal file
@ -0,0 +1,10 @@
|
||||
enum Font {
|
||||
roboto('Roboto'),
|
||||
robotoSlab('Roboto Slab'),
|
||||
ubuntu('Ubuntu'),
|
||||
ubuntuMono('Ubuntu Mono');
|
||||
|
||||
const Font(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
5
lib/models/item/buildable.dart
Normal file
5
lib/models/item/buildable.dart
Normal file
@ -0,0 +1,5 @@
|
||||
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
|
||||
|
||||
mixin Buildable {
|
||||
List<LinkifyElement> get elements;
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/models/comment.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/models/item/buildable.dart';
|
||||
import 'package:hacki/models/item/comment.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
class BuildableComment extends Comment {
|
||||
/// [BuildableComment] is a subtype of [Comment] which stores
|
||||
/// the corresponding [LinkifyElement] for faster widget building.
|
||||
class BuildableComment extends Comment with Buildable {
|
||||
BuildableComment({
|
||||
required super.id,
|
||||
required super.time,
|
||||
@ -31,5 +33,6 @@ class BuildableComment extends Comment {
|
||||
level: comment.level,
|
||||
);
|
||||
|
||||
@override
|
||||
final List<LinkifyElement> elements;
|
||||
}
|
46
lib/models/item/buildable_story.dart
Normal file
46
lib/models/item/buildable_story.dart
Normal file
@ -0,0 +1,46 @@
|
||||
import 'package:hacki/models/item/buildable.dart';
|
||||
import 'package:hacki/models/item/story.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
/// [BuildableStory] is a subtype of [Story] which stores
|
||||
/// the corresponding [LinkifyElement] for faster widget building.
|
||||
class BuildableStory extends Story with Buildable {
|
||||
const BuildableStory({
|
||||
required super.id,
|
||||
required super.time,
|
||||
required super.score,
|
||||
required super.by,
|
||||
required super.text,
|
||||
required super.kids,
|
||||
required super.descendants,
|
||||
required super.title,
|
||||
required super.type,
|
||||
required super.url,
|
||||
required super.parts,
|
||||
required this.elements,
|
||||
});
|
||||
|
||||
BuildableStory.fromStory(Story story, {required this.elements})
|
||||
: super(
|
||||
id: story.id,
|
||||
time: story.time,
|
||||
score: story.score,
|
||||
by: story.by,
|
||||
text: story.text,
|
||||
kids: story.kids,
|
||||
descendants: story.descendants,
|
||||
title: story.title,
|
||||
type: story.type,
|
||||
url: story.url,
|
||||
parts: story.parts,
|
||||
);
|
||||
|
||||
BuildableStory.fromTitleOnlyStory(Story story)
|
||||
: this.fromStory(
|
||||
story,
|
||||
elements: const <LinkifyElement>[],
|
||||
);
|
||||
|
||||
@override
|
||||
final List<LinkifyElement> elements;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import 'package:hacki/models/item.dart';
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
|
||||
class Comment extends Item {
|
||||
Comment({
|
||||
@ -41,38 +41,6 @@ class Comment extends Item {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'id': id,
|
||||
'time': time,
|
||||
'by': by,
|
||||
'text': text,
|
||||
'kids': kids,
|
||||
'parent': parent,
|
||||
'deleted': deleted,
|
||||
'dead': dead,
|
||||
'score': score,
|
||||
'level': level,
|
||||
};
|
||||
|
||||
@override
|
||||
bool? get stringify => false;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
score,
|
||||
descendants,
|
||||
time,
|
||||
by,
|
||||
title,
|
||||
url,
|
||||
kids,
|
||||
dead,
|
||||
parts,
|
||||
deleted,
|
||||
parent,
|
||||
text,
|
||||
type,
|
||||
];
|
||||
}
|
@ -1,6 +1,17 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/extensions/date_time_extension.dart';
|
||||
import 'package:hacki/models/item/comment.dart';
|
||||
import 'package:hacki/models/item/poll_option.dart';
|
||||
import 'package:hacki/models/item/story.dart';
|
||||
|
||||
export 'buildable.dart';
|
||||
export 'buildable_comment.dart';
|
||||
export 'buildable_story.dart';
|
||||
export 'comment.dart';
|
||||
export 'poll_option.dart';
|
||||
export 'story.dart';
|
||||
|
||||
/// [Item] is the base type of [Story], [Comment] and [PollOption].
|
||||
class Item extends Equatable {
|
||||
const Item({
|
||||
required this.id,
|
||||
@ -97,6 +108,7 @@ class Item extends Equatable {
|
||||
'deleted': deleted,
|
||||
'type': type,
|
||||
'parts': parts,
|
||||
'parent': parent,
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hacki/models/item.dart';
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
|
||||
class PollOption extends Item {
|
||||
const PollOption({
|
||||
@ -52,19 +52,7 @@ class PollOption extends Item {
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'descendants': descendants,
|
||||
'id': id,
|
||||
'score': score,
|
||||
'time': time,
|
||||
'by': by,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'kids': kids,
|
||||
'text': text,
|
||||
'dead': dead,
|
||||
'deleted': deleted,
|
||||
'type': type,
|
||||
'parts': parts,
|
||||
...super.toJson(),
|
||||
'ratio': ratio,
|
||||
};
|
||||
}
|
||||
@ -75,22 +63,4 @@ class PollOption extends Item {
|
||||
const JsonEncoder.withIndent(' ').convert(this);
|
||||
return 'PollOption $prettyString';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
score,
|
||||
descendants,
|
||||
time,
|
||||
by,
|
||||
title,
|
||||
url,
|
||||
kids,
|
||||
dead,
|
||||
parts,
|
||||
deleted,
|
||||
parent,
|
||||
text,
|
||||
type,
|
||||
];
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/item.dart';
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
|
||||
class Story extends Item {
|
||||
const Story({
|
||||
@ -54,25 +54,6 @@ class Story extends Item {
|
||||
return authority;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'descendants': descendants,
|
||||
'id': id,
|
||||
'score': score,
|
||||
'time': time,
|
||||
'by': by,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'kids': kids,
|
||||
'text': text,
|
||||
'dead': dead,
|
||||
'deleted': deleted,
|
||||
'type': type,
|
||||
'parts': parts,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
// final String prettyString =
|
||||
@ -80,23 +61,4 @@ class Story extends Item {
|
||||
// return 'Story $prettyString';
|
||||
return 'Story $id';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
score,
|
||||
descendants,
|
||||
time,
|
||||
by,
|
||||
title,
|
||||
text,
|
||||
url,
|
||||
kids,
|
||||
dead,
|
||||
parts,
|
||||
deleted,
|
||||
parent,
|
||||
text,
|
||||
type,
|
||||
];
|
||||
}
|
@ -1,13 +1,10 @@
|
||||
export 'buildable_comment.dart';
|
||||
export 'comment.dart';
|
||||
export 'comments_order.dart';
|
||||
export 'fetch_mode.dart';
|
||||
export 'font.dart';
|
||||
export 'font_size.dart';
|
||||
export 'item.dart';
|
||||
export 'poll_option.dart';
|
||||
export 'item/item.dart';
|
||||
export 'post_data.dart';
|
||||
export 'preference.dart';
|
||||
export 'search_params.dart';
|
||||
export 'story.dart';
|
||||
export 'story_type.dart';
|
||||
export 'user.dart';
|
||||
|
@ -20,6 +20,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
// Order of these first four preferences does not matter.
|
||||
FetchModePreference(),
|
||||
CommentsOrderPreference(),
|
||||
FontPreference(),
|
||||
FontSizePreference(),
|
||||
TabOrderPreference(),
|
||||
// Order of items below matters and
|
||||
@ -65,6 +66,7 @@ const bool _collapseModeDefaultValue = true;
|
||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||
final int _fontSizeDefaultValue = FontSize.regular.index;
|
||||
final int _fontDefaultValue = Font.roboto.index;
|
||||
final int _tabOrderDefaultValue =
|
||||
StoryType.convertToSettingsValue(StoryType.values);
|
||||
|
||||
@ -325,6 +327,21 @@ class CommentsOrderPreference extends IntPreference {
|
||||
String get title => 'Default comments order';
|
||||
}
|
||||
|
||||
class FontPreference extends IntPreference {
|
||||
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
|
||||
|
||||
@override
|
||||
FontPreference copyWith({required int? val}) {
|
||||
return FontPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'font';
|
||||
|
||||
@override
|
||||
String get title => 'Default font';
|
||||
}
|
||||
|
||||
class FontSizePreference extends IntPreference {
|
||||
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);
|
||||
|
||||
|
@ -8,8 +8,19 @@ abstract class NumericFilter extends SearchFilter {}
|
||||
|
||||
abstract class TagFilter extends SearchFilter {}
|
||||
|
||||
abstract class TypeTagFilter extends TagFilter {
|
||||
static List<TypeTagFilter> all = <TypeTagFilter>[
|
||||
const StoryFilter(),
|
||||
const PollFilter(),
|
||||
const CommentFilter(),
|
||||
const FrontPageFilter(),
|
||||
const AskHnFilter(),
|
||||
const ShowHnFilter(),
|
||||
];
|
||||
}
|
||||
|
||||
class DateTimeRangeFilter implements NumericFilter {
|
||||
DateTimeRangeFilter({
|
||||
const DateTimeRangeFilter({
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
});
|
||||
@ -37,7 +48,7 @@ class DateTimeRangeFilter implements NumericFilter {
|
||||
}
|
||||
|
||||
class PostedByFilter implements TagFilter {
|
||||
PostedByFilter({required this.author});
|
||||
const PostedByFilter({required this.author});
|
||||
|
||||
final String author;
|
||||
|
||||
@ -47,8 +58,8 @@ class PostedByFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class FrontPageFilter implements TagFilter {
|
||||
FrontPageFilter();
|
||||
class FrontPageFilter implements TypeTagFilter {
|
||||
const FrontPageFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -56,8 +67,8 @@ class FrontPageFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class ShowHnFilter implements TagFilter {
|
||||
ShowHnFilter();
|
||||
class ShowHnFilter implements TypeTagFilter {
|
||||
const ShowHnFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -65,8 +76,8 @@ class ShowHnFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class AskHnFilter implements TagFilter {
|
||||
AskHnFilter();
|
||||
class AskHnFilter implements TypeTagFilter {
|
||||
const AskHnFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -74,8 +85,8 @@ class AskHnFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class PollFilter implements TagFilter {
|
||||
PollFilter();
|
||||
class PollFilter implements TypeTagFilter {
|
||||
const PollFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -83,8 +94,8 @@ class PollFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class StoryFilter implements TagFilter {
|
||||
StoryFilter();
|
||||
class StoryFilter implements TypeTagFilter {
|
||||
const StoryFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
@ -92,8 +103,17 @@ class StoryFilter implements TagFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class CommentFilter implements TypeTagFilter {
|
||||
const CommentFilter();
|
||||
|
||||
@override
|
||||
String get query {
|
||||
return 'comment';
|
||||
}
|
||||
}
|
||||
|
||||
class CombinedFilter implements TagFilter {
|
||||
CombinedFilter({required this.filters});
|
||||
const CombinedFilter({required this.filters});
|
||||
|
||||
final List<TagFilter> filters;
|
||||
|
||||
|
@ -70,7 +70,6 @@ class SearchParams extends Equatable {
|
||||
filters.whereType<NumericFilter>();
|
||||
final List<TagFilter> tagFilters = <TagFilter>[
|
||||
...filters.whereType<TagFilter>(),
|
||||
CombinedFilter(filters: <TagFilter>[StoryFilter(), PollFilter()]),
|
||||
];
|
||||
|
||||
if (numericFilters.isNotEmpty) {
|
||||
|
@ -2,10 +2,16 @@ import 'dart:async';
|
||||
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/post_repository.dart';
|
||||
import 'package:hacki/repositories/postable_repository.dart';
|
||||
import 'package:hacki/repositories/preference_repository.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// [AuthRepository] if for logging user in/out and performing actions
|
||||
/// that require a logged in user such as [flag], [favorite], [upvote],
|
||||
/// and [downvote].
|
||||
///
|
||||
/// For posting actions such as posting a comment, see [PostRepository].
|
||||
class AuthRepository extends PostableRepository {
|
||||
AuthRepository({
|
||||
super.dio,
|
||||
|
@ -4,9 +4,14 @@ import 'package:hacki/models/models.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// [OfflineRepository] is for storing stories and comments for offline reading.
|
||||
/// It's using [Hive] as its database which is being stored in temp directory.
|
||||
/// [OfflineRepository] is for storing [Story] and [Comment] for
|
||||
/// offline reading.
|
||||
///
|
||||
/// [Hive] is used as its database and is being stored in the temporary
|
||||
/// directory assigned by host system which you can retrieve
|
||||
/// by calling [getTemporaryDirectory].
|
||||
class OfflineRepository {
|
||||
OfflineRepository({
|
||||
Future<Box<List<int>>>? storyIdBox,
|
||||
@ -36,7 +41,7 @@ class OfflineRepository {
|
||||
_storyBox.then((Box<Map<dynamic, dynamic>> box) => box.isNotEmpty);
|
||||
|
||||
Future<void> cacheStoryIds({
|
||||
required StoryType of,
|
||||
required StoryType type,
|
||||
required List<int> ids,
|
||||
}) async {
|
||||
late final Box<List<int>> box;
|
||||
@ -49,7 +54,7 @@ class OfflineRepository {
|
||||
box = await _storyIdBox;
|
||||
}
|
||||
|
||||
return box.put(of.name, ids);
|
||||
return box.put(type.name, ids);
|
||||
}
|
||||
|
||||
Future<void> cacheStory({required Story story}) async {
|
||||
@ -103,10 +108,10 @@ class OfflineRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>> getCachedStoryIds({required StoryType of}) async {
|
||||
Future<List<int>> getCachedStoryIds({required StoryType type}) async {
|
||||
try {
|
||||
final Box<List<int>> box = await _storyIdBox;
|
||||
final List<int>? ids = box.get(of.name);
|
||||
final List<int>? ids = box.get(type.name);
|
||||
return ids ?? <int>[];
|
||||
} catch (_) {
|
||||
_logger.e(_);
|
||||
|
@ -7,6 +7,7 @@ import 'package:hacki/repositories/postable_repository.dart';
|
||||
import 'package:hacki/repositories/preference_repository.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
/// [PostRepository] is for posting contents to Hacker News.
|
||||
class PostRepository extends PostableRepository {
|
||||
PostRepository({super.dio, PreferenceRepository? storageRepository})
|
||||
: _preferenceRepository =
|
||||
|
@ -3,8 +3,12 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/auth_repository.dart';
|
||||
import 'package:hacki/repositories/post_repository.dart';
|
||||
import 'package:hacki/utils/service_exception.dart';
|
||||
|
||||
/// [PostableRepository] is solely for hosting functionalities shared between
|
||||
/// [AuthRepository] and [PostRepository].
|
||||
class PostableRepository {
|
||||
PostableRepository({
|
||||
Dio? dio,
|
||||
|
@ -7,6 +7,7 @@ import 'package:logger/logger.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:synced_shared_preferences/synced_shared_preferences.dart';
|
||||
|
||||
/// [PreferenceRepository] is for storing user preferences.
|
||||
class PreferenceRepository {
|
||||
PreferenceRepository({
|
||||
SyncedSharedPreferences? syncedPrefs,
|
||||
|
@ -3,6 +3,9 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
/// [SearchRepository] is for searching contents on Hacker News.
|
||||
///
|
||||
/// You can learn about the search API at https://hn.algolia.com/api.
|
||||
class SearchRepository {
|
||||
SearchRepository({Dio? dio}) : _dio = dio ?? Dio();
|
||||
|
||||
@ -10,7 +13,7 @@ class SearchRepository {
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Stream<Story> search({
|
||||
Stream<Item> search({
|
||||
required SearchParams params,
|
||||
}) async* {
|
||||
final String url = '$_baseUrl${params.filteredQuery}';
|
||||
@ -33,37 +36,53 @@ class SearchRepository {
|
||||
final int score = hit['points'] as int? ?? 0;
|
||||
final int descendants = hit['num_comments'] as int? ?? 0;
|
||||
|
||||
// Getting rid of comments, only keeping stories for convenience.
|
||||
// Don't judge me.
|
||||
if (title.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String url = hit['url'] as String? ?? '';
|
||||
final String type =
|
||||
title.toLowerCase().contains('poll:') ? 'poll' : 'story';
|
||||
final String text = hit['story_text'] as String? ?? '';
|
||||
final String parsedText = await compute<String, String>(
|
||||
HtmlUtil.parseHtml,
|
||||
text,
|
||||
);
|
||||
final int id = int.parse(hit['objectID'] as String? ?? '0');
|
||||
|
||||
final Story story = Story(
|
||||
descendants: descendants,
|
||||
id: id,
|
||||
score: score,
|
||||
time: createdAt,
|
||||
by: by,
|
||||
title: title,
|
||||
text: parsedText,
|
||||
url: url,
|
||||
type: type,
|
||||
// response doesn't contain kids and parts.
|
||||
kids: const <int>[],
|
||||
parts: const <int>[],
|
||||
);
|
||||
yield story;
|
||||
if (title.isEmpty) {
|
||||
final String text = hit['comment_text'] as String? ?? '';
|
||||
final String parsedText = await compute<String, String>(
|
||||
HtmlUtil.parseHtml,
|
||||
text,
|
||||
);
|
||||
final int parentId = hit['parent_id'] as int? ?? 0;
|
||||
final Comment comment = Comment(
|
||||
id: id,
|
||||
score: score,
|
||||
time: createdAt,
|
||||
by: by,
|
||||
text: parsedText,
|
||||
kids: const <int>[],
|
||||
parent: parentId,
|
||||
dead: false,
|
||||
deleted: false,
|
||||
level: 0,
|
||||
);
|
||||
yield comment;
|
||||
} else {
|
||||
final String text = hit['story_text'] as String? ?? '';
|
||||
final String parsedText = await compute<String, String>(
|
||||
HtmlUtil.parseHtml,
|
||||
text,
|
||||
);
|
||||
final Story story = Story(
|
||||
descendants: descendants,
|
||||
id: id,
|
||||
score: score,
|
||||
time: createdAt,
|
||||
by: by,
|
||||
title: title,
|
||||
text: parsedText,
|
||||
url: url,
|
||||
type: type,
|
||||
// response doesn't contain kids and parts.
|
||||
kids: const <int>[],
|
||||
parts: const <int>[],
|
||||
);
|
||||
yield story;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -7,7 +7,10 @@ import 'package:sembast/sembast.dart';
|
||||
import 'package:sembast/sembast_io.dart';
|
||||
|
||||
/// [SembastRepository] is for storing stories and comments for faster loading.
|
||||
/// It's using Sembast as its database which is being stored in doc directory.
|
||||
///
|
||||
/// Sembast [Database] is used as its database and is being stored in the
|
||||
/// documents directory assigned by host system which you can retrieve
|
||||
/// by calling [getApplicationDocumentsDirectory].
|
||||
class SembastRepository {
|
||||
SembastRepository({Database? database}) {
|
||||
if (database == null) {
|
||||
|
@ -4,6 +4,11 @@ import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
/// [StoriesRepository] is for fetching
|
||||
/// [Item] such as [Story], [PollOption], [Comment] or [User].
|
||||
///
|
||||
/// You can learn more about the Hacker News API at
|
||||
/// https://github.com/HackerNews/API.
|
||||
class StoriesRepository {
|
||||
StoriesRepository({
|
||||
FirebaseClient? firebaseClient,
|
||||
@ -12,9 +17,66 @@ class StoriesRepository {
|
||||
final FirebaseClient _firebaseClient;
|
||||
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
|
||||
|
||||
Future<User> fetchUserBy({required String userId}) async {
|
||||
Future<Map<String, dynamic>?> _fetchItemJson(int id) async {
|
||||
return _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _fetchRawItemJson(int id) async {
|
||||
return _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic value) => value as Map<String, dynamic>?);
|
||||
}
|
||||
|
||||
/// Fetch a [Item] based on its id.
|
||||
Future<Item?> fetchItem({required int id}) async {
|
||||
final Item? item =
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
|
||||
final String type = json['type'] as String;
|
||||
if (type == 'story' || type == 'job' || type == 'poll') {
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (type == 'comment') {
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// Fetch a raw [Item] based on its id.
|
||||
/// The content of [Item] will not be parsed, use this function only if
|
||||
/// the format of content doesn't matter, otherwise, use [fetchItem].
|
||||
Future<Item?> fetchRawItem({required int id}) async {
|
||||
final Item? item = await _fetchRawItemJson(id).then((dynamic val) {
|
||||
if (val == null) return null;
|
||||
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
|
||||
final String type = json['type'] as String;
|
||||
if (type == 'story' || type == 'job' || type == 'poll') {
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (type == 'comment') {
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// Fetch a [User] by its [id].
|
||||
/// Hacker News uses user's username as [id].
|
||||
Future<User> fetchUser({required String id}) async {
|
||||
final User user = await _firebaseClient
|
||||
.get('${_baseUrl}user/$userId.json')
|
||||
.get('${_baseUrl}user/$id.json')
|
||||
.then((dynamic val) {
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
final User user = User.fromJson(json);
|
||||
@ -24,9 +86,27 @@ class StoriesRepository {
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<List<int>> fetchStoryIds({required StoryType of}) async {
|
||||
/// Fetch a list of ids of [Story] or [Comment] submitted by the user.
|
||||
Future<List<int>?> fetchSubmitted({required String userId}) async {
|
||||
final List<int>? submitted = await _firebaseClient
|
||||
.get('${_baseUrl}user/$userId.json')
|
||||
.then((dynamic val) {
|
||||
if (val == null) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
final List<int> submitted =
|
||||
(json['submitted'] as List<dynamic>? ?? <dynamic>[]).cast<int>();
|
||||
return submitted;
|
||||
});
|
||||
|
||||
return submitted;
|
||||
}
|
||||
|
||||
/// Fetch ids of stories of a certain [StoryType].
|
||||
Future<List<int>> fetchStoryIds({required StoryType type}) async {
|
||||
final List<int> ids = await _firebaseClient
|
||||
.get('$_baseUrl${of.path}.json')
|
||||
.get('$_baseUrl${type.path}.json')
|
||||
.then((dynamic val) {
|
||||
final List<int> ids = (val as List<dynamic>).cast<int>();
|
||||
return ids;
|
||||
@ -35,11 +115,10 @@ class StoriesRepository {
|
||||
return ids;
|
||||
}
|
||||
|
||||
Future<Story?> fetchStoryBy(int id) async {
|
||||
final Story? story = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
|
||||
.then((Map<String, dynamic>? json) {
|
||||
/// Fetch a [Story] based on its id.
|
||||
Future<Story?> fetchStory({required int id}) async {
|
||||
final Story? story =
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
@ -48,6 +127,90 @@ class StoriesRepository {
|
||||
return story;
|
||||
}
|
||||
|
||||
/// Fetch a [Comment] based on its id.
|
||||
Future<Comment?> fetchComment({required int id}) async {
|
||||
final Comment? comment =
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
|
||||
if (json == null) return null;
|
||||
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
/// Fetch a raw [Comment] based on its id.
|
||||
/// The content of [Comment] will not be parsed, use this function only if
|
||||
/// the format of content doesn't matter, otherwise, use [fetchComment].
|
||||
Future<Comment?> fetchRawComment({required int id}) async {
|
||||
final Comment? comment =
|
||||
await _fetchRawItemJson(id).then((dynamic val) async {
|
||||
if (val == null) return null;
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
/// Fetch the parent [Story] of a [Comment].
|
||||
Future<Story?> fetchParentStory({required int id}) async {
|
||||
Item? item;
|
||||
|
||||
do {
|
||||
item = await fetchItem(id: item?.parent ?? id);
|
||||
if (item == null) return null;
|
||||
} while (item is Comment);
|
||||
|
||||
return item as Story;
|
||||
}
|
||||
|
||||
/// Fetch the raw parent [Story] of a [Comment].
|
||||
/// The content of [Story] will not be parsed, use this function only if
|
||||
/// the format of content doesn't matter, otherwise, use [fetchParentStory].
|
||||
Future<Story?> fetchRawParentStory({required int id}) async {
|
||||
Item? item;
|
||||
|
||||
do {
|
||||
item = await fetchRawItem(id: item?.parent ?? id);
|
||||
if (item == null) return null;
|
||||
} while (item is Comment);
|
||||
|
||||
return item as Story;
|
||||
}
|
||||
|
||||
/// Fetch the parent [Story] of a [Comment] as well as
|
||||
/// the list of [Comment] traversed in order to reach the parent.
|
||||
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments({
|
||||
required int id,
|
||||
}) async {
|
||||
Item? item;
|
||||
final List<Comment> parentComments = <Comment>[];
|
||||
|
||||
do {
|
||||
item = await fetchItem(id: item?.parent ?? id);
|
||||
if (item is Comment) {
|
||||
parentComments.add(item);
|
||||
}
|
||||
if (item == null) return null;
|
||||
} while (item is Comment);
|
||||
|
||||
for (int i = 0; i < parentComments.length; i++) {
|
||||
parentComments[i] =
|
||||
parentComments[i].copyWith(level: parentComments.length - i - 1);
|
||||
}
|
||||
|
||||
return Tuple2<Story, List<Comment>>(
|
||||
item as Story,
|
||||
parentComments.reversed.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch a list of [Comment] based on ids and return results
|
||||
/// using a stream.
|
||||
Stream<Comment> fetchCommentsStream({
|
||||
required List<int> ids,
|
||||
int level = 0,
|
||||
@ -56,10 +219,8 @@ class StoriesRepository {
|
||||
for (final int id in ids) {
|
||||
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
|
||||
|
||||
comment ??= await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
|
||||
.then((Map<String, dynamic>? json) async {
|
||||
comment ??=
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
|
||||
if (json == null) return null;
|
||||
|
||||
final Comment comment = Comment.fromJson(json, level: level);
|
||||
@ -73,6 +234,8 @@ class StoriesRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
/// Fetch a list of [Comment] based on ids recursively and
|
||||
/// return results using a stream.
|
||||
Stream<Comment> fetchAllCommentsRecursivelyStream({
|
||||
required List<int> ids,
|
||||
int level = 0,
|
||||
@ -81,10 +244,8 @@ class StoriesRepository {
|
||||
for (final int id in ids) {
|
||||
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
|
||||
|
||||
comment ??= await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
|
||||
.then((Map<String, dynamic>? json) async {
|
||||
comment ??=
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
|
||||
if (json == null) return null;
|
||||
|
||||
final Comment comment = Comment.fromJson(json, level: level);
|
||||
@ -104,19 +265,19 @@ class StoriesRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
/// Fetch a list of [Item] based on ids and return results
|
||||
/// using a stream.
|
||||
Stream<Item> fetchItemsStream({required List<int> ids}) async* {
|
||||
for (final int id in ids) {
|
||||
final Item? item = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
|
||||
.then((Map<String, dynamic>? json) async {
|
||||
final Item? item =
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
|
||||
if (json == null) return null;
|
||||
|
||||
final String type = json['type'] as String;
|
||||
if (type == 'story' || type == 'job') {
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (json['type'] == 'comment') {
|
||||
} else if (type == 'comment') {
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
@ -129,12 +290,12 @@ class StoriesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a list of [Story] based on ids and return results
|
||||
/// using a stream.
|
||||
Stream<Story> fetchStoriesStream({required List<int> ids}) async* {
|
||||
for (final int id in ids) {
|
||||
final Story? story = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
|
||||
.then((Map<String, dynamic>? json) async {
|
||||
final Story? story =
|
||||
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
|
||||
if (json == null) return null;
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
@ -146,11 +307,12 @@ class StoriesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a list of [PollOption] based on ids and return results
|
||||
/// using a stream.
|
||||
Stream<PollOption> fetchPollOptionsStream({required List<int> ids}) async* {
|
||||
for (final int id in ids) {
|
||||
final PollOption? option = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic json) async {
|
||||
final PollOption? option =
|
||||
await _fetchRawItemJson(id).then((dynamic json) async {
|
||||
if (json == null) return null;
|
||||
final PollOption option =
|
||||
PollOption.fromJson(json as Map<String, dynamic>);
|
||||
@ -163,143 +325,10 @@ class StoriesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Comment?> fetchCommentBy({required int id}) async {
|
||||
final Comment? comment = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
|
||||
.then((Map<String, dynamic>? json) async {
|
||||
if (json == null) return null;
|
||||
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
Future<Comment?> fetchRawCommentBy({required int id}) async {
|
||||
final Comment? comment = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic val) async {
|
||||
if (val == null) return null;
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
Future<Item?> fetchItemBy({required int id}) async {
|
||||
final Item? item = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
|
||||
.then((Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
|
||||
final String type = json['type'] as String;
|
||||
if (type == 'story' || type == 'job' || type == 'poll') {
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (json['type'] == 'comment') {
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
Future<Item?> fetchRawItemBy({required int id}) async {
|
||||
final Item? item = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic val) {
|
||||
if (val == null) return null;
|
||||
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
|
||||
final String type = json['type'] as String;
|
||||
if (type == 'story' || type == 'job' || type == 'poll') {
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (json['type'] == 'comment') {
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
Future<List<int>?> fetchSubmitted({required String of}) async {
|
||||
final List<int>? submitted = await _firebaseClient
|
||||
.get('${_baseUrl}user/$of.json')
|
||||
.then((dynamic val) {
|
||||
if (val == null) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
final List<int> submitted =
|
||||
(json['submitted'] as List<dynamic>? ?? <dynamic>[]).cast<int>();
|
||||
return submitted;
|
||||
});
|
||||
|
||||
return submitted;
|
||||
}
|
||||
|
||||
Future<Story?> fetchParentStory({required int id}) async {
|
||||
Item? item;
|
||||
|
||||
do {
|
||||
item = await fetchItemBy(id: item?.parent ?? id);
|
||||
if (item == null) return null;
|
||||
} while (item is Comment);
|
||||
|
||||
return item as Story;
|
||||
}
|
||||
|
||||
Future<Story?> fetchRawParentStory({required int id}) async {
|
||||
Item? item;
|
||||
|
||||
do {
|
||||
item = await fetchRawItemBy(id: item?.parent ?? id);
|
||||
if (item == null) return null;
|
||||
} while (item is Comment);
|
||||
|
||||
return item as Story;
|
||||
}
|
||||
|
||||
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments({
|
||||
required int id,
|
||||
}) async {
|
||||
Item? item;
|
||||
final List<Comment> parentComments = <Comment>[];
|
||||
|
||||
do {
|
||||
item = await fetchItemBy(id: item?.parent ?? id);
|
||||
if (item is Comment) {
|
||||
parentComments.add(item);
|
||||
}
|
||||
if (item == null) return null;
|
||||
} while (item is Comment);
|
||||
|
||||
for (int i = 0; i < parentComments.length; i++) {
|
||||
parentComments[i] =
|
||||
parentComments[i].copyWith(level: parentComments.length - i - 1);
|
||||
}
|
||||
|
||||
return Tuple2<Story, List<Comment>>(
|
||||
item as Story,
|
||||
parentComments.reversed.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch a list of [Comment] based on ids recursively.
|
||||
Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* {
|
||||
for (final int id in ids) {
|
||||
final Comment? comment = await fetchCommentBy(id: id);
|
||||
final Comment? comment = await fetchComment(id: id);
|
||||
if (comment != null) {
|
||||
yield comment;
|
||||
yield* fetchAllChildrenComments(ids: comment.kids);
|
||||
@ -307,7 +336,10 @@ class StoriesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _parseJson(Map<String, dynamic>? json) async {
|
||||
/// Parse the json of an [Item] by removing useless HTML tags.
|
||||
static Future<Map<String, dynamic>?> _parseJson(
|
||||
Map<String, dynamic>? json,
|
||||
) async {
|
||||
if (json == null) return null;
|
||||
final String text = json['text'] as String? ?? '';
|
||||
final String parsedText = await compute<String, String>(
|
||||
|
@ -92,14 +92,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
SchedulerBinding.instance
|
||||
..addPostFrameCallback((_) {
|
||||
if (!isTesting) {
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
const <String>{
|
||||
Constants.featureLogIn,
|
||||
},
|
||||
);
|
||||
}
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
<String>{
|
||||
Constants.featureLogIn,
|
||||
},
|
||||
);
|
||||
})
|
||||
..addPostFrameCallback((_) {
|
||||
final ModalRoute<dynamic>? route = ModalRoute.of(context);
|
||||
@ -278,7 +276,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
final int? id = event.itemId;
|
||||
|
||||
if (id != null) {
|
||||
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
|
||||
locator.get<StoriesRepository>().fetchItem(id: id).then((Item? item) {
|
||||
if (mounted) {
|
||||
if (item != null) {
|
||||
goToItemScreen(
|
||||
@ -298,7 +296,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchStoryBy(storyId)
|
||||
.fetchStory(id: storyId)
|
||||
.then((Story? story) {
|
||||
if (story == null) {
|
||||
showErrorSnackBar();
|
||||
@ -323,7 +321,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchStoryBy(storyId)
|
||||
.fetchStory(id: storyId)
|
||||
.then((Story? story) {
|
||||
if (story == null) {
|
||||
showErrorSnackBar();
|
||||
|
@ -1,5 +1,3 @@
|
||||
// ignore_for_file: comment_references
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -11,8 +9,8 @@ import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/item/widgets/widgets.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
@ -33,7 +31,7 @@ class ItemScreenArgs extends Equatable {
|
||||
final List<Comment>? targetComments;
|
||||
|
||||
/// when a user is trying to view a sub-thread from a main thread, we don't
|
||||
/// need to fetch comments from [StoryRepository] since we have some, if not
|
||||
/// need to fetch comments from [StoriesRepository] since we have some, if not
|
||||
/// all, comments cached in [CommentCache].
|
||||
final bool useCommentCache;
|
||||
|
||||
@ -76,7 +74,7 @@ class ItemScreen extends StatefulWidget {
|
||||
context.read<PreferenceCubit>().state.order,
|
||||
)..init(
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetParents: args.targetComments,
|
||||
targetAncestors: args.targetComments,
|
||||
useCommentCache: args.useCommentCache,
|
||||
),
|
||||
),
|
||||
@ -118,7 +116,7 @@ class ItemScreen extends StatefulWidget {
|
||||
context.read<PreferenceCubit>().state.order,
|
||||
)..init(
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetParents: args.targetComments,
|
||||
targetAncestors: args.targetComments,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -175,16 +173,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
|
||||
SchedulerBinding.instance
|
||||
..addPostFrameCallback((_) {
|
||||
if (!isTesting) {
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
const <String>{
|
||||
Constants.featurePinToTop,
|
||||
Constants.featureAddStoryToFavList,
|
||||
Constants.featureOpenStoryInWebView,
|
||||
},
|
||||
);
|
||||
}
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
<String>{
|
||||
Constants.featurePinToTop,
|
||||
Constants.featureAddStoryToFavList,
|
||||
Constants.featureOpenStoryInWebView,
|
||||
},
|
||||
);
|
||||
})
|
||||
..addPostFrameCallback((_) {
|
||||
final ModalRoute<dynamic>? route = ModalRoute.of(context);
|
||||
@ -293,8 +289,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
topPadding: topPadding,
|
||||
splitViewEnabled: widget.splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onLoginTapped: onLoginTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
),
|
||||
),
|
||||
@ -318,8 +312,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
.withOpacity(0.6),
|
||||
item: widget.item,
|
||||
scrollController: scrollController,
|
||||
onBackgroundTap: onFeatureDiscoveryDismissed,
|
||||
onDismiss: onFeatureDiscoveryDismissed,
|
||||
splitViewEnabled: state.enabled,
|
||||
expanded: state.expanded,
|
||||
onZoomTap:
|
||||
@ -359,8 +351,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
Theme.of(context).canvasColor.withOpacity(0.6),
|
||||
item: widget.item,
|
||||
scrollController: scrollController,
|
||||
onBackgroundTap: onFeatureDiscoveryDismissed,
|
||||
onDismiss: onFeatureDiscoveryDismissed,
|
||||
onFontSizeTap: onFontSizeTapped,
|
||||
fontSizeIconButtonKey: fontSizeIconButtonKey,
|
||||
),
|
||||
@ -373,8 +363,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
topPadding: topPadding,
|
||||
splitViewEnabled: widget.splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onLoginTapped: onLoginTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
),
|
||||
bottomSheet: ReplyBox(
|
||||
@ -396,15 +384,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> onFeatureDiscoveryDismissed() {
|
||||
featureDiscoveryDismissThrottle.run(() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
showSnackBar(content: 'Tap on icon to continue');
|
||||
});
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
|
||||
void onFontSizeTapped() {
|
||||
const Offset offset = Offset.zero;
|
||||
final RenderBox overlay =
|
||||
@ -470,7 +449,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.av_timer),
|
||||
title: const Text('View parents'),
|
||||
title: const Text('View ancestors'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onTimeMachineActivated(comment);
|
||||
@ -514,7 +493,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
size: size,
|
||||
deviceType: deviceType,
|
||||
widthFactor: widthFactor,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -1,6 +1,7 @@
|
||||
enum MenuAction {
|
||||
upvote,
|
||||
downvote,
|
||||
fav,
|
||||
share,
|
||||
block,
|
||||
flag,
|
||||
|
@ -11,8 +11,6 @@ class CustomAppBar extends AppBar {
|
||||
required ScrollController scrollController,
|
||||
required Item item,
|
||||
required Color super.backgroundColor,
|
||||
required Future<bool> Function() onBackgroundTap,
|
||||
required Future<bool> Function() onDismiss,
|
||||
required VoidCallback onFontSizeTap,
|
||||
required GlobalKey fontSizeIconButtonKey,
|
||||
bool splitViewEnabled = false,
|
||||
@ -41,26 +39,26 @@ class CustomAppBar extends AppBar {
|
||||
),
|
||||
IconButton(
|
||||
key: fontSizeIconButtonKey,
|
||||
icon: const Icon(
|
||||
Icons.format_size,
|
||||
icon: Text(
|
||||
String.fromCharCode(FeatherIcons.type.codePoint),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: TextDimens.pt18,
|
||||
fontFamily: FeatherIcons.type.fontFamily,
|
||||
package: FeatherIcons.type.fontPackage,
|
||||
),
|
||||
),
|
||||
onPressed: onFontSizeTap,
|
||||
),
|
||||
if (item is Story)
|
||||
PinIconButton(
|
||||
story: item,
|
||||
onBackgroundTap: onBackgroundTap,
|
||||
onDismiss: onDismiss,
|
||||
),
|
||||
FavIconButton(
|
||||
storyId: item.id,
|
||||
onBackgroundTap: onBackgroundTap,
|
||||
onDismiss: onDismiss,
|
||||
),
|
||||
LinkIconButton(
|
||||
storyId: item.id,
|
||||
onBackgroundTap: onBackgroundTap,
|
||||
onDismiss: onDismiss,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -1,24 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
class FavIconButton extends StatelessWidget {
|
||||
const FavIconButton({
|
||||
super.key,
|
||||
required this.storyId,
|
||||
required this.onBackgroundTap,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final int storyId;
|
||||
final Future<bool> Function() onBackgroundTap;
|
||||
final Future<bool> Function() onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -27,15 +21,7 @@ class FavIconButton extends StatelessWidget {
|
||||
final bool isFav = favState.favIds.contains(storyId);
|
||||
return IconButton(
|
||||
tooltip: 'Add to favorites',
|
||||
icon: DescribedFeatureOverlay(
|
||||
onBackgroundTap: onBackgroundTap,
|
||||
onDismiss: onDismiss,
|
||||
onComplete: () async {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
return true;
|
||||
},
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
icon: CustomDescribedFeatureOverlay(
|
||||
tapTarget: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_border,
|
||||
color: Palette.white,
|
||||
|
@ -1,9 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
@ -11,40 +8,28 @@ class LinkIconButton extends StatelessWidget {
|
||||
const LinkIconButton({
|
||||
super.key,
|
||||
required this.storyId,
|
||||
required this.onBackgroundTap,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final int storyId;
|
||||
final Future<bool> Function() onBackgroundTap;
|
||||
final Future<bool> Function() onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: 'Open this story in browser',
|
||||
icon: DescribedFeatureOverlay(
|
||||
onBackgroundTap: onBackgroundTap,
|
||||
onDismiss: onDismiss,
|
||||
onComplete: () async {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
return true;
|
||||
},
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: const Icon(
|
||||
icon: const CustomDescribedFeatureOverlay(
|
||||
tapTarget: Icon(
|
||||
Icons.stream,
|
||||
color: Palette.white,
|
||||
),
|
||||
featureId: Constants.featureOpenStoryInWebView,
|
||||
title: const Text('Open in Browser'),
|
||||
description: const Text(
|
||||
title: Text('Open in Browser'),
|
||||
description: Text(
|
||||
'Want more than just reading and replying? '
|
||||
'You can tap here to open this story in a '
|
||||
'browser.',
|
||||
style: TextStyle(fontSize: TextDimens.pt16),
|
||||
),
|
||||
child: const Icon(
|
||||
child: Icon(
|
||||
Icons.stream,
|
||||
),
|
||||
),
|
||||
|
@ -2,25 +2,21 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class LoginDialog extends StatelessWidget {
|
||||
const LoginDialog({
|
||||
super.key,
|
||||
required this.usernameController,
|
||||
required this.passwordController,
|
||||
required this.showSnackBar,
|
||||
});
|
||||
class LoginDialog extends StatefulWidget {
|
||||
const LoginDialog({super.key});
|
||||
|
||||
final TextEditingController usernameController;
|
||||
final TextEditingController passwordController;
|
||||
final void Function({
|
||||
required String content,
|
||||
VoidCallback? action,
|
||||
String? label,
|
||||
}) showSnackBar;
|
||||
@override
|
||||
State<LoginDialog> createState() => _LoginDialogState();
|
||||
}
|
||||
|
||||
class _LoginDialogState extends State<LoginDialog> {
|
||||
final TextEditingController usernameController = TextEditingController();
|
||||
final TextEditingController passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -90,9 +86,10 @@ class LoginDialog extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt18,
|
||||
right: Dimens.pt6,
|
||||
),
|
||||
child: Text(
|
||||
Constants.errorMessage,
|
||||
Constants.loginErrorMessage,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
fontSize: TextDimens.pt12,
|
||||
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
@ -26,8 +25,6 @@ class MainView extends StatelessWidget {
|
||||
required this.topPadding,
|
||||
required this.splitViewEnabled,
|
||||
required this.onMoreTapped,
|
||||
required this.onStoryLinkTapped,
|
||||
required this.onLoginTapped,
|
||||
required this.onRightMoreTapped,
|
||||
});
|
||||
|
||||
@ -39,8 +36,6 @@ class MainView extends StatelessWidget {
|
||||
final double topPadding;
|
||||
final bool splitViewEnabled;
|
||||
final void Function(Item item, Rect? rect) onMoreTapped;
|
||||
final ValueChanged<String> onStoryLinkTapped;
|
||||
final VoidCallback onLoginTapped;
|
||||
final ValueChanged<Comment> onRightMoreTapped;
|
||||
|
||||
static const int _loadingIndicatorOpacityAnimationDuration = 300;
|
||||
@ -124,8 +119,6 @@ class MainView extends StatelessWidget {
|
||||
topPadding: topPadding,
|
||||
splitViewEnabled: splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onLoginTapped: onLoginTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
);
|
||||
} else if (index == state.comments.length + 1) {
|
||||
@ -150,8 +143,6 @@ class MainView extends StatelessWidget {
|
||||
child: CommentTile(
|
||||
comment: comment,
|
||||
level: comment.level,
|
||||
myUsername:
|
||||
authState.isLoggedIn ? authState.username : null,
|
||||
opUsername: state.item.by,
|
||||
fetchMode: state.fetchMode,
|
||||
onReplyTapped: (Comment cmt) {
|
||||
@ -178,7 +169,6 @@ class MainView extends StatelessWidget {
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
onMoreTapped: onMoreTapped,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
),
|
||||
);
|
||||
@ -225,8 +215,6 @@ class _ParentItemSection extends StatelessWidget {
|
||||
required this.topPadding,
|
||||
required this.splitViewEnabled,
|
||||
required this.onMoreTapped,
|
||||
required this.onStoryLinkTapped,
|
||||
required this.onLoginTapped,
|
||||
required this.onRightMoreTapped,
|
||||
});
|
||||
|
||||
@ -239,8 +227,6 @@ class _ParentItemSection extends StatelessWidget {
|
||||
final double topPadding;
|
||||
final bool splitViewEnabled;
|
||||
final void Function(Item item, Rect? rect) onMoreTapped;
|
||||
final ValueChanged<String> onStoryLinkTapped;
|
||||
final VoidCallback onLoginTapped;
|
||||
final ValueChanged<Comment> onRightMoreTapped;
|
||||
|
||||
@override
|
||||
@ -340,15 +326,11 @@ class _ParentItemSection extends StatelessWidget {
|
||||
bottom: Dimens.pt12,
|
||||
top: Dimens.pt12,
|
||||
),
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: MediaQuery.of(
|
||||
context,
|
||||
).textScaleFactor *
|
||||
prefState.fontSize.fontSize,
|
||||
fontSize: prefState.fontSize.fontSize,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
@ -359,10 +341,7 @@ class _ParentItemSection extends StatelessWidget {
|
||||
text: state.item.title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: MediaQuery.of(
|
||||
context,
|
||||
).textScaleFactor *
|
||||
prefState.fontSize.fontSize,
|
||||
fontSize: prefState.fontSize.fontSize,
|
||||
color: state.item.url.isNotEmpty
|
||||
? Palette.orange
|
||||
: null,
|
||||
@ -374,15 +353,17 @@ class _ParentItemSection extends StatelessWidget {
|
||||
''' (${(state.item as Story).readableUrl})''',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: MediaQuery.of(
|
||||
context,
|
||||
).textScaleFactor *
|
||||
(prefState.fontSize.fontSize - 4),
|
||||
fontSize:
|
||||
prefState.fontSize.fontSize - 4,
|
||||
color: Palette.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
textScaleFactor: MediaQuery.of(
|
||||
context,
|
||||
).textScaleFactor,
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -391,36 +372,15 @@ class _ParentItemSection extends StatelessWidget {
|
||||
height: Dimens.pt6,
|
||||
),
|
||||
if (state.item.text.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt10,
|
||||
),
|
||||
child: SelectableLinkify(
|
||||
text: state.item.text,
|
||||
style: TextStyle(
|
||||
fontSize: MediaQuery.of(context).textScaleFactor *
|
||||
context
|
||||
.read<PreferenceCubit>()
|
||||
.state
|
||||
.fontSize
|
||||
.fontSize,
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt10,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
fontSize: MediaQuery.of(context).textScaleFactor *
|
||||
context
|
||||
.read<PreferenceCubit>()
|
||||
.state
|
||||
.fontSize
|
||||
.fontSize,
|
||||
color: Palette.orange,
|
||||
child: ItemText(
|
||||
item: state.item,
|
||||
),
|
||||
onOpen: (LinkableElement link) {
|
||||
if (link.url.isStoryLink) {
|
||||
onStoryLinkTapped(link.url);
|
||||
} else {
|
||||
LinkUtil.launch(link.url);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -431,9 +391,7 @@ class _ParentItemSection extends StatelessWidget {
|
||||
BlocProvider<PollCubit>(
|
||||
create: (BuildContext context) =>
|
||||
PollCubit(story: state.item as Story)..init(),
|
||||
child: PollView(
|
||||
onLoginTapped: onLoginTapped,
|
||||
),
|
||||
child: const PollView(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -1,12 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/item/models/models.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
@ -15,15 +18,24 @@ class MorePopupMenu extends StatelessWidget {
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.isBlocked,
|
||||
required this.onStoryLinkTapped,
|
||||
required this.onLoginTapped,
|
||||
});
|
||||
|
||||
final Item item;
|
||||
final bool isBlocked;
|
||||
final ValueChanged<String> onStoryLinkTapped;
|
||||
final VoidCallback onLoginTapped;
|
||||
|
||||
static double? _cachedStoryHeight;
|
||||
static double? _cachedCommentHeight;
|
||||
|
||||
static double get storyHeight {
|
||||
return _cachedStoryHeight ??= Platform.isIOS ? 500 : 530;
|
||||
}
|
||||
|
||||
static double get commentHeight {
|
||||
return _cachedCommentHeight ??= Platform.isIOS ? 480 : 520;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<VoteCubit>(
|
||||
@ -68,7 +80,7 @@ class MorePopupMenu extends StatelessWidget {
|
||||
final bool upvoted = voteState.vote == Vote.up;
|
||||
final bool downvoted = voteState.vote == Vote.down;
|
||||
return Container(
|
||||
height: item is Comment ? 430 : 450,
|
||||
height: item is Comment ? commentHeight : storyHeight,
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
color: Palette.transparent,
|
||||
@ -88,6 +100,7 @@ class MorePopupMenu extends StatelessWidget {
|
||||
state.user.description,
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
@ -112,15 +125,19 @@ class MorePopupMenu extends StatelessWidget {
|
||||
linkStyle: const TextStyle(
|
||||
color: Palette.orange,
|
||||
),
|
||||
onOpen: (LinkableElement link) {
|
||||
if (link.url.isStoryLink) {
|
||||
onStoryLinkTapped.call(link.url);
|
||||
} else {
|
||||
LinkUtil.launch(link.url);
|
||||
}
|
||||
},
|
||||
onOpen: (LinkableElement link) =>
|
||||
LinkUtil.launch,
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
onSearchUserTapped(context);
|
||||
},
|
||||
child: const Text(
|
||||
'Search',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
@ -163,6 +180,24 @@ class MorePopupMenu extends StatelessWidget {
|
||||
),
|
||||
onTap: context.read<VoteCubit>().downvote,
|
||||
),
|
||||
BlocBuilder<FavCubit, FavState>(
|
||||
builder: (BuildContext context, FavState state) {
|
||||
final bool isFav = state.favIds.contains(item.id);
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFav ? Palette.orange : null,
|
||||
),
|
||||
title: Text(
|
||||
isFav ? 'Unfavorite' : 'Favorite',
|
||||
),
|
||||
onTap: () => Navigator.pop(
|
||||
context,
|
||||
MenuAction.fav,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(FeatherIcons.share),
|
||||
title: const Text(
|
||||
@ -213,4 +248,45 @@ class MorePopupMenu extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onSearchUserTapped(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext context) {
|
||||
return BlocProvider<SearchCubit>(
|
||||
create: (_) => SearchCubit()
|
||||
..addFilter(
|
||||
PostedByFilter(
|
||||
author: item.by,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height - Dimens.pt120,
|
||||
color: Theme.of(context).canvasColor,
|
||||
margin: const EdgeInsets.only(top: Dimens.pt12),
|
||||
child: Material(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
height: Dimens.pt4,
|
||||
width: Dimens.pt24,
|
||||
decoration: BoxDecoration(
|
||||
color: Palette.grey,
|
||||
borderRadius: BorderRadius.circular(Dimens.pt16),
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: SearchScreen(
|
||||
fromUserDialog: true,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,26 +1,21 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
class PinIconButton extends StatelessWidget {
|
||||
const PinIconButton({
|
||||
super.key,
|
||||
required this.story,
|
||||
required this.onBackgroundTap,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final Story story;
|
||||
final Future<bool> Function() onBackgroundTap;
|
||||
final Future<bool> Function() onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -33,15 +28,7 @@ class PinIconButton extends StatelessWidget {
|
||||
offset: const Offset(2, 0),
|
||||
child: IconButton(
|
||||
tooltip: 'Pin to home screen',
|
||||
icon: DescribedFeatureOverlay(
|
||||
onBackgroundTap: onBackgroundTap,
|
||||
onDismiss: onDismiss,
|
||||
onComplete: () async {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
return true;
|
||||
},
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
icon: CustomDescribedFeatureOverlay(
|
||||
tapTarget: Icon(
|
||||
pinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
color: Palette.white,
|
||||
|
@ -4,18 +4,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/context_extension.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
class PollView extends StatelessWidget {
|
||||
const PollView({
|
||||
super.key,
|
||||
required this.onLoginTapped,
|
||||
});
|
||||
class PollView extends StatefulWidget {
|
||||
const PollView({super.key});
|
||||
|
||||
final VoidCallback onLoginTapped;
|
||||
@override
|
||||
State<PollView> createState() => _PollViewState();
|
||||
}
|
||||
|
||||
class _PollViewState extends State<PollView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PollCubit, PollState>(
|
||||
|
@ -3,13 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/item.dart';
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/link_util.dart';
|
||||
|
||||
class ReplyBox extends StatefulWidget {
|
||||
const ReplyBox({
|
||||
@ -256,6 +255,8 @@ class _ReplyBoxState extends State<ReplyBox> {
|
||||
void showTextPopup() {
|
||||
final Item? replyingTo = context.read<EditCubit>().state.replyingTo;
|
||||
|
||||
if (replyingTo == null) return;
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
@ -280,37 +281,49 @@ class _ReplyBoxState extends State<ReplyBox> {
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
replyingTo?.by ?? '',
|
||||
style: const TextStyle(color: Palette.grey),
|
||||
replyingTo.by,
|
||||
style: const TextStyle(
|
||||
fontSize: TextDimens.pt14,
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (replyingTo != null)
|
||||
TextButton(
|
||||
child: const Text('View thread'),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
expanded = false;
|
||||
});
|
||||
Navigator.popUntil(
|
||||
context,
|
||||
(Route<dynamic> route) =>
|
||||
route.settings.name == ItemScreen.routeName ||
|
||||
route.isFirst,
|
||||
);
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: replyingTo,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: const Text('Copy all'),
|
||||
child: const Text(
|
||||
'View thread',
|
||||
style: TextStyle(
|
||||
fontSize: TextDimens.pt14,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
expanded = false;
|
||||
});
|
||||
Navigator.popUntil(
|
||||
context,
|
||||
(Route<dynamic> route) =>
|
||||
route.settings.name == ItemScreen.routeName ||
|
||||
route.isFirst,
|
||||
);
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: replyingTo,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: const Text(
|
||||
'Copy all',
|
||||
style: TextStyle(
|
||||
fontSize: TextDimens.pt14,
|
||||
),
|
||||
),
|
||||
onPressed: () => FlutterClipboard.copy(
|
||||
replyingTo?.text ?? '',
|
||||
replyingTo.text,
|
||||
).then((_) => HapticFeedback.selectionClick()),
|
||||
),
|
||||
IconButton(
|
||||
@ -334,16 +347,8 @@ class _ReplyBoxState extends State<ReplyBox> {
|
||||
top: Dimens.pt6,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableLinkify(
|
||||
scrollPhysics: const NeverScrollableScrollPhysics(),
|
||||
linkStyle: TextStyle(
|
||||
fontSize: MediaQuery.of(context).textScaleFactor *
|
||||
TextDimens.pt15,
|
||||
color: Palette.orange,
|
||||
),
|
||||
onOpen: (LinkableElement link) =>
|
||||
LinkUtil.launch(link.url),
|
||||
text: replyingTo?.text ?? '',
|
||||
child: ItemText(
|
||||
item: replyingTo,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
@ -14,14 +13,12 @@ class TimeMachineDialog extends StatelessWidget {
|
||||
required this.size,
|
||||
required this.deviceType,
|
||||
required this.widthFactor,
|
||||
required this.onStoryLinkTapped,
|
||||
});
|
||||
|
||||
final Comment comment;
|
||||
final Size size;
|
||||
final DeviceScreenType deviceType;
|
||||
final double widthFactor;
|
||||
final void Function(String) onStoryLinkTapped;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -52,7 +49,7 @@ class TimeMachineDialog extends StatelessWidget {
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
const Text('Parents:'),
|
||||
const Text('Ancestors:'),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
@ -67,12 +64,10 @@ class TimeMachineDialog extends StatelessWidget {
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
for (final Comment c in state.parents) ...<Widget>[
|
||||
for (final Comment c
|
||||
in state.ancestors) ...<Widget>[
|
||||
CommentTile(
|
||||
comment: c,
|
||||
myUsername:
|
||||
context.read<AuthBloc>().state.username,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
actionable: false,
|
||||
fetchMode: FetchMode.eager,
|
||||
),
|
||||
|
@ -35,15 +35,6 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
|
||||
PageType pageType = PageType.notification;
|
||||
|
||||
final List<String> magicWords = <String>[
|
||||
'to be a lord.',
|
||||
'to conquer the world.',
|
||||
'to be over the rainbow!',
|
||||
'to bless humanity with long-lasting peace.',
|
||||
'to save the world',
|
||||
'to infinity and beyond!',
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
@ -57,7 +48,6 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final String magicWord = (magicWords..shuffle()).first;
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (BuildContext context, AuthState authState) {
|
||||
return BlocConsumer<NotificationCubit, NotificationState>(
|
||||
@ -239,9 +229,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
Settings(
|
||||
authState: authState,
|
||||
magicWord: magicWord,
|
||||
magicWord: Constants.magicWord,
|
||||
pageType: pageType,
|
||||
onLoginTapped: onLoginTapped,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
@ -384,193 +373,6 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void onLoginTapped() {
|
||||
final TextEditingController usernameController = TextEditingController();
|
||||
final TextEditingController passwordController = TextEditingController();
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return BlocConsumer<AuthBloc, AuthState>(
|
||||
listener: (BuildContext context, AuthState state) {
|
||||
if (state.isLoggedIn) {
|
||||
Navigator.pop(context);
|
||||
showSnackBar(content: 'Logged in successfully!');
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, AuthState state) {
|
||||
return SimpleDialog(
|
||||
children: <Widget>[
|
||||
if (state.status == AuthStatus.loading)
|
||||
const SizedBox(
|
||||
height: Dimens.pt36,
|
||||
width: Dimens.pt36,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Palette.orange,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!state.isLoggedIn) ...<Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt18,
|
||||
),
|
||||
child: TextField(
|
||||
controller: usernameController,
|
||||
cursorColor: Palette.orange,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Username',
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Palette.orange),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt16,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt18,
|
||||
),
|
||||
child: TextField(
|
||||
controller: passwordController,
|
||||
cursorColor: Palette.orange,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Password',
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Palette.orange),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt16,
|
||||
),
|
||||
if (state.status == AuthStatus.failure)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt18,
|
||||
),
|
||||
child: Text(
|
||||
Constants.errorMessage,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
fontSize: TextDimens.pt12,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
state.agreedToEULA
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank,
|
||||
color: state.agreedToEULA
|
||||
? Palette.deepOrange
|
||||
: Palette.grey,
|
||||
),
|
||||
onPressed: () => context
|
||||
.read<AuthBloc>()
|
||||
.add(AuthToggleAgreeToEULA()),
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: <InlineSpan>[
|
||||
const TextSpan(
|
||||
text: 'I agree to ',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
WidgetSpan(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, 1),
|
||||
child: TapDownWrapper(
|
||||
onTap: () => LinkUtil.launch(
|
||||
Constants.endUserAgreementLink,
|
||||
),
|
||||
child: const Text(
|
||||
'End User Agreement',
|
||||
style: TextStyle(
|
||||
color: Palette.deepOrange,
|
||||
decoration: TextDecoration.underline,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: Dimens.pt12,
|
||||
),
|
||||
child: ButtonBar(
|
||||
children: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
context.read<AuthBloc>().add(AuthInitialize());
|
||||
},
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (state.agreedToEULA) {
|
||||
final String username = usernameController.text;
|
||||
final String password = passwordController.text;
|
||||
if (username.isNotEmpty && password.isNotEmpty) {
|
||||
context.read<AuthBloc>().add(
|
||||
AuthLogin(
|
||||
username: username,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
state.agreedToEULA
|
||||
? Palette.deepOrange
|
||||
: Palette.grey,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Log in',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Palette.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
@ -32,13 +32,11 @@ class Settings extends StatefulWidget {
|
||||
required this.authState,
|
||||
required this.magicWord,
|
||||
required this.pageType,
|
||||
required this.onLoginTapped,
|
||||
});
|
||||
|
||||
final AuthState authState;
|
||||
final String magicWord;
|
||||
final PageType pageType;
|
||||
final VoidCallback onLoginTapped;
|
||||
|
||||
@override
|
||||
State<Settings> createState() => _SettingsState();
|
||||
@ -69,7 +67,7 @@ class _SettingsState extends State<Settings> {
|
||||
if (widget.authState.isLoggedIn) {
|
||||
onLogoutTapped();
|
||||
} else {
|
||||
widget.onLoginTapped();
|
||||
onLoginTapped();
|
||||
}
|
||||
},
|
||||
),
|
||||
@ -219,6 +217,12 @@ class _SettingsState extends State<Settings> {
|
||||
},
|
||||
activeColor: Palette.orange,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Font',
|
||||
),
|
||||
onTap: showFontSettingDialog,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Theme',
|
||||
@ -285,6 +289,56 @@ class _SettingsState extends State<Settings> {
|
||||
);
|
||||
}
|
||||
|
||||
void showFontSettingDialog() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.font != current.font,
|
||||
builder: (BuildContext context, PreferenceState state) {
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
for (final Font font in Font.values)
|
||||
RadioListTile<Font>(
|
||||
value: font,
|
||||
groupValue: state.font,
|
||||
onChanged: (Font? val) {
|
||||
if (val != null) {
|
||||
context.read<PreferenceCubit>().update(
|
||||
FontPreference(),
|
||||
to: val.index,
|
||||
);
|
||||
}
|
||||
},
|
||||
title: Text(
|
||||
font.label,
|
||||
style: TextStyle(fontFamily: font.name),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: const <Widget>[
|
||||
Text(
|
||||
'*Restart required',
|
||||
style: TextStyle(
|
||||
fontSize: TextDimens.pt12,
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void showThemeSettingDialog() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
|
@ -12,7 +12,15 @@ import 'package:hacki/utils/utils.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
const SearchScreen({
|
||||
super.key,
|
||||
this.fromUserDialog = false,
|
||||
});
|
||||
|
||||
/// If user gets to [SearchScreen] from user dialog on Tablet,
|
||||
/// we navigate to [ItemScreen] directly instead of injecting the
|
||||
/// item into [SplitViewCubit].
|
||||
final bool fromUserDialog;
|
||||
|
||||
@override
|
||||
_SearchScreenState createState() => _SearchScreenState();
|
||||
@ -37,6 +45,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@ -68,18 +77,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeRangeFilterChip(
|
||||
filter: state.params.get<DateTimeRangeFilter>(),
|
||||
onDateTimeRangeUpdated:
|
||||
(DateTime start, DateTime end) =>
|
||||
context.read<SearchCubit>().addFilter(
|
||||
DateTimeRangeFilter(
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
),
|
||||
),
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
onDateTimeRangeRemoved: context
|
||||
.read<SearchCubit>()
|
||||
.removeFilter<DateTimeRangeFilter>,
|
||||
@ -87,6 +91,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
PostedByFilterChip(
|
||||
filter: state.params.get<PostedByFilter>(),
|
||||
onChanged:
|
||||
context.read<SearchCubit>().onPostedByChanged,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
CustomChip(
|
||||
onSelected: (_) =>
|
||||
context.read<SearchCubit>().onSortToggled(),
|
||||
@ -100,13 +112,9 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
in CustomDateTimeRange.values) ...<Widget>[
|
||||
CustomRangeFilterChip(
|
||||
range: range,
|
||||
onTap: (DateTime start, DateTime end) =>
|
||||
context.read<SearchCubit>().addFilter(
|
||||
DateTimeRangeFilter(
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
),
|
||||
),
|
||||
onTap: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
@ -115,12 +123,52 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
for (final TypeTagFilter filter
|
||||
in TypeTagFilter.all) ...<Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
CustomChip(
|
||||
onSelected: (_) =>
|
||||
context.read<SearchCubit>().onToggled(filter),
|
||||
selected: context
|
||||
.read<SearchCubit>()
|
||||
.state
|
||||
.params
|
||||
.get<TypeTagFilter>() ==
|
||||
filter,
|
||||
label: filter.query,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.status == SearchStatus.loading &&
|
||||
state.results.isEmpty) ...<Widget>[
|
||||
const SizedBox(
|
||||
height: Dimens.pt100,
|
||||
),
|
||||
const CustomCircularProgressIndicator(),
|
||||
const Center(
|
||||
child: CustomCircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
if (state.status == SearchStatus.loaded &&
|
||||
state.results.isEmpty) ...<Widget>[
|
||||
const SizedBox(
|
||||
height: Dimens.pt100,
|
||||
),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Nothing found...',
|
||||
style: TextStyle(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: SmartRefresher(
|
||||
@ -160,19 +208,33 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
children: <Widget>[
|
||||
...state.results
|
||||
.map(
|
||||
(Story e) => <Widget>[
|
||||
FadeIn(
|
||||
child: StoryTile(
|
||||
showWebPreview:
|
||||
prefState.complexStoryTileEnabled,
|
||||
showMetadata: prefState.metadataEnabled,
|
||||
showUrl: prefState.urlEnabled,
|
||||
story: e,
|
||||
onTap: () => goToItemScreen(
|
||||
args: ItemScreenArgs(item: e),
|
||||
(Item e) => <Widget>[
|
||||
if (e is Story)
|
||||
FadeIn(
|
||||
child: StoryTile(
|
||||
showWebPreview:
|
||||
prefState.complexStoryTileEnabled,
|
||||
showMetadata: prefState.metadataEnabled,
|
||||
showUrl: prefState.urlEnabled,
|
||||
story: e,
|
||||
onTap: () => goToItemScreen(
|
||||
args: ItemScreenArgs(item: e),
|
||||
forceNewScreen: widget.fromUserDialog,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (e is Comment)
|
||||
FadeIn(
|
||||
child: CommentTile(
|
||||
actionable: false,
|
||||
comment: e,
|
||||
fetchMode: FetchMode.eager,
|
||||
onTap: () => goToItemScreen(
|
||||
args: ItemScreenArgs(item: e),
|
||||
forceNewScreen: widget.fromUserDialog,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!prefState.complexStoryTileEnabled)
|
||||
const Divider(
|
||||
height: Dimens.zero,
|
||||
|
@ -35,7 +35,7 @@ class DateTimeRangeFilterChip extends StatelessWidget {
|
||||
},
|
||||
selected: filter != null,
|
||||
label:
|
||||
'''from ${_formatDateTime(filter?.startTime) ?? 'START DATE'} to ${_formatDateTime(filter?.endTime) ?? 'END DATE'}''',
|
||||
'''from ${_formatDateTime(filter?.startTime) ?? 'X'} to ${_formatDateTime(filter?.endTime) ?? 'Y'}''',
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,21 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/models/search_params.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
class PostedByFilterChip extends StatelessWidget {
|
||||
const PostedByFilterChip({
|
||||
super.key,
|
||||
required this.filter,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final PostedByFilter? filter;
|
||||
final ValueChanged<String?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomChip(
|
||||
onSelected: (bool value) {},
|
||||
onSelected: (_) async {
|
||||
final String? username = await onChipTapped(context);
|
||||
if (username == filter?.author) {
|
||||
return;
|
||||
}
|
||||
onChanged(username);
|
||||
},
|
||||
selected: filter != null,
|
||||
label: '''posted by ${filter?.author ?? ''}''',
|
||||
label: '''posted by ${filter?.author ?? ''}'''.trimRight(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> onChipTapped(BuildContext context) async {
|
||||
final TextEditingController usernameController = TextEditingController();
|
||||
|
||||
if (filter?.author != null) {
|
||||
usernameController.text = filter!.author;
|
||||
}
|
||||
|
||||
final String? username = await showDialog<String?>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return SimpleDialog(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt18,
|
||||
),
|
||||
child: TextField(
|
||||
controller: usernameController,
|
||||
cursorColor: Palette.orange,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Username',
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Palette.orange),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt16,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: Dimens.pt12,
|
||||
),
|
||||
child: ButtonBar(
|
||||
children: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, filter?.author),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: const Text(
|
||||
'Clear',
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final String text = usernameController.text.trim();
|
||||
Navigator.pop(context, text.isEmpty ? null : text);
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor:
|
||||
MaterialStateProperty.all(Palette.deepOrange),
|
||||
),
|
||||
child: const Text(
|
||||
'Confirm',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Palette.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/bloc_builder_3.dart';
|
||||
import 'package:hacki/screens/widgets/centered_text.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class CommentTile extends StatelessWidget {
|
||||
const CommentTile({
|
||||
super.key,
|
||||
required this.myUsername,
|
||||
required this.comment,
|
||||
required this.onStoryLinkTapped,
|
||||
required this.fetchMode,
|
||||
this.onReplyTapped,
|
||||
this.onMoreTapped,
|
||||
@ -27,19 +22,22 @@ class CommentTile extends StatelessWidget {
|
||||
this.opUsername,
|
||||
this.actionable = true,
|
||||
this.level = 0,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String? myUsername;
|
||||
final String? opUsername;
|
||||
final Comment comment;
|
||||
final int level;
|
||||
final bool actionable;
|
||||
final FetchMode fetchMode;
|
||||
|
||||
final void Function(Comment)? onReplyTapped;
|
||||
final void Function(Comment, Rect?)? onMoreTapped;
|
||||
final void Function(Comment)? onEditTapped;
|
||||
final void Function(Comment)? onRightMoreTapped;
|
||||
final void Function(String) onStoryLinkTapped;
|
||||
final FetchMode fetchMode;
|
||||
|
||||
/// Override for search screen.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
static final Map<int, Color> _colors = <int, Color>{};
|
||||
|
||||
@ -122,6 +120,8 @@ class CommentTile extends StatelessWidget {
|
||||
if (actionable) {
|
||||
HapticFeedback.selectionClick();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
} else {
|
||||
onTap?.call();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
@ -186,61 +186,20 @@ class CommentTile extends StatelessWidget {
|
||||
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: ItemText(
|
||||
key: ValueKey<int>(comment.id),
|
||||
item: comment,
|
||||
onTap: () {
|
||||
if (onTap == null) {
|
||||
_onTextTapped(context);
|
||||
} else {
|
||||
onTap!.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -291,7 +250,8 @@ class CommentTile extends StatelessWidget {
|
||||
final Color commentColor = prefState.eyeCandyEnabled
|
||||
? color.withOpacity(commentBackgroundColorOpacity)
|
||||
: Palette.transparent;
|
||||
final bool isMyComment = myUsername == comment.by;
|
||||
final bool isMyComment = comment.deleted == false &&
|
||||
context.read<AuthBloc>().state.username == comment.by;
|
||||
|
||||
Widget wrapper = child;
|
||||
|
||||
@ -335,13 +295,6 @@ class CommentTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void onTextTapped(BuildContext context) {
|
||||
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
|
||||
HapticFeedback.selectionClick();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
}
|
||||
}
|
||||
|
||||
Color _getColor(int level) {
|
||||
final int initialLevel = level;
|
||||
if (_colors[initialLevel] != null) return _colors[initialLevel]!;
|
||||
@ -372,11 +325,19 @@ class CommentTile extends StatelessWidget {
|
||||
|
||||
bool _shouldShowLoadButton(BuildContext context) {
|
||||
final CollapseState collapseState = context.read<CollapseCubit>().state;
|
||||
final CommentsState commentsState = context.read<CommentsCubit>().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;
|
||||
commentsState?.commentIds.contains(comment.kids.first) == false &&
|
||||
commentsState?.onlyShowTargetComment == false;
|
||||
}
|
||||
|
||||
void _onTextTapped(BuildContext context) {
|
||||
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
|
||||
HapticFeedback.selectionClick();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart' show ReminderCubit, ReminderState;
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/story.dart';
|
||||
import 'package:hacki/models/item/story.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
@ -108,7 +108,7 @@ class _CountDownReminderState extends State<CountdownReminder>
|
||||
if (state.storyId != null) {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchStoryBy(state.storyId!)
|
||||
.fetchStory(id: state.storyId!)
|
||||
.then((Story? story) {
|
||||
if (story == null) {
|
||||
showErrorSnackBar();
|
||||
|
49
lib/screens/widgets/custom_described_feature_overlay.dart
Normal file
49
lib/screens/widgets/custom_described_feature_overlay.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
403
lib/screens/widgets/custom_linkify/custom_linkify.dart
Normal file
403
lib/screens/widgets/custom_linkify/custom_linkify.dart
Normal file
@ -0,0 +1,403 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
|
||||
import 'package:hacki/styles/palette.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
export 'package:linkify/linkify.dart'
|
||||
show
|
||||
LinkifyElement,
|
||||
LinkifyOptions,
|
||||
LinkableElement,
|
||||
TextElement,
|
||||
Linkifier,
|
||||
UrlElement,
|
||||
UrlLinkifier,
|
||||
EmailElement,
|
||||
EmailLinkifier;
|
||||
|
||||
/// Callback clicked link
|
||||
typedef LinkCallback = void Function(LinkableElement link);
|
||||
|
||||
/// Turns URLs into links
|
||||
class Linkify extends StatelessWidget {
|
||||
const Linkify({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.linkifiers = defaultLinkifiers,
|
||||
this.onOpen,
|
||||
this.options = const LinkifyOptions(),
|
||||
// TextSpan
|
||||
this.style,
|
||||
this.linkStyle,
|
||||
// RichText
|
||||
this.textAlign = TextAlign.start,
|
||||
this.textDirection,
|
||||
this.maxLines,
|
||||
this.overflow = TextOverflow.clip,
|
||||
this.textScaleFactor = 1.0,
|
||||
this.softWrap = true,
|
||||
this.strutStyle,
|
||||
this.locale,
|
||||
this.textWidthBasis = TextWidthBasis.parent,
|
||||
this.textHeightBehavior,
|
||||
});
|
||||
|
||||
/// Text to be linkified
|
||||
final String text;
|
||||
|
||||
/// Linkifiers to be used for linkify
|
||||
final List<Linkifier> linkifiers;
|
||||
|
||||
/// Callback for tapping a link
|
||||
final LinkCallback? onOpen;
|
||||
|
||||
/// linkify's options.
|
||||
final LinkifyOptions options;
|
||||
|
||||
// TextSpan
|
||||
|
||||
/// Style for non-link text
|
||||
final TextStyle? style;
|
||||
|
||||
/// Style of link text
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
// Text.rich
|
||||
|
||||
/// How the text should be aligned horizontally.
|
||||
final TextAlign textAlign;
|
||||
|
||||
/// Text direction of the text
|
||||
final TextDirection? textDirection;
|
||||
|
||||
/// The maximum number of lines for the text to span, wrapping if necessary
|
||||
final int? maxLines;
|
||||
|
||||
/// How visual overflow should be handled.
|
||||
final TextOverflow overflow;
|
||||
|
||||
/// The number of font pixels for each logical pixel
|
||||
final double textScaleFactor;
|
||||
|
||||
/// Whether the text should break at soft line breaks.
|
||||
final bool softWrap;
|
||||
|
||||
/// The strut style used for the vertical layout
|
||||
final StrutStyle? strutStyle;
|
||||
|
||||
/// Used to select a font when the same Unicode character can
|
||||
/// be rendered differently, depending on the locale
|
||||
final Locale? locale;
|
||||
|
||||
/// Defines how to measure the width of the rendered text.
|
||||
final TextWidthBasis textWidthBasis;
|
||||
|
||||
/// Defines how the paragraph will apply TextStyle.height to the ascent of
|
||||
/// the first line and descent of the last line.
|
||||
final TextHeightBehavior? textHeightBehavior;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<LinkifyElement> elements = linkify(
|
||||
text,
|
||||
options: options,
|
||||
linkifiers: linkifiers,
|
||||
);
|
||||
|
||||
return Text.rich(
|
||||
buildTextSpan(
|
||||
elements,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.merge(style),
|
||||
onOpen: onOpen,
|
||||
useMouseRegion: true,
|
||||
linkStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.merge(style)
|
||||
.copyWith(
|
||||
color: Colors.blueAccent,
|
||||
decoration: TextDecoration.underline,
|
||||
)
|
||||
.merge(linkStyle),
|
||||
),
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
textScaleFactor: textScaleFactor,
|
||||
softWrap: softWrap,
|
||||
strutStyle: strutStyle,
|
||||
locale: locale,
|
||||
textWidthBasis: textWidthBasis,
|
||||
textHeightBehavior: textHeightBehavior,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const UrlLinkifier _urlLinkifier = UrlLinkifier();
|
||||
const EmailLinkifier _emailLinkifier = EmailLinkifier();
|
||||
const QuoteLinkifier _quoteLinkifier = QuoteLinkifier();
|
||||
const EmphasisLinkifier _emphasisLinkifier = EmphasisLinkifier();
|
||||
const List<Linkifier> defaultLinkifiers = <Linkifier>[
|
||||
_urlLinkifier,
|
||||
_emailLinkifier,
|
||||
_quoteLinkifier,
|
||||
_emphasisLinkifier,
|
||||
];
|
||||
|
||||
/// Turns URLs into links
|
||||
class SelectableLinkify extends StatelessWidget {
|
||||
const SelectableLinkify({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.linkifiers = defaultLinkifiers,
|
||||
this.onOpen,
|
||||
this.options = const LinkifyOptions(),
|
||||
// TextSpan
|
||||
this.style,
|
||||
this.linkStyle,
|
||||
// RichText
|
||||
this.textAlign,
|
||||
this.textDirection,
|
||||
this.minLines,
|
||||
this.maxLines,
|
||||
// SelectableText
|
||||
this.focusNode,
|
||||
this.textScaleFactor = 1.0,
|
||||
this.strutStyle,
|
||||
this.showCursor = false,
|
||||
this.autofocus = false,
|
||||
this.cursorWidth = 2.0,
|
||||
this.cursorRadius,
|
||||
this.cursorColor,
|
||||
this.dragStartBehavior = DragStartBehavior.start,
|
||||
this.enableInteractiveSelection = true,
|
||||
this.onTap,
|
||||
this.scrollPhysics,
|
||||
this.textWidthBasis,
|
||||
this.textHeightBehavior,
|
||||
this.cursorHeight,
|
||||
this.selectionControls,
|
||||
this.onSelectionChanged,
|
||||
this.contextMenuBuilder = _defaultContextMenuBuilder,
|
||||
});
|
||||
|
||||
/// Text to be linkified
|
||||
final String text;
|
||||
|
||||
/// The number of font pixels for each logical pixel
|
||||
final double textScaleFactor;
|
||||
|
||||
/// Linkifiers to be used for linkify
|
||||
final List<Linkifier> linkifiers;
|
||||
|
||||
/// Callback for tapping a link
|
||||
final LinkCallback? onOpen;
|
||||
|
||||
/// linkify's options.
|
||||
final LinkifyOptions options;
|
||||
|
||||
// TextSpan
|
||||
|
||||
/// Style for non-link text
|
||||
final TextStyle? style;
|
||||
|
||||
/// Style of link text
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
// Text.rich
|
||||
|
||||
/// How the text should be aligned horizontally.
|
||||
final TextAlign? textAlign;
|
||||
|
||||
/// Text direction of the text
|
||||
final TextDirection? textDirection;
|
||||
|
||||
/// The minimum number of lines to occupy when the content spans fewer lines.
|
||||
final int? minLines;
|
||||
|
||||
/// The maximum number of lines for the text to span, wrapping if necessary
|
||||
final int? maxLines;
|
||||
|
||||
/// The strut style used for the vertical layout
|
||||
final StrutStyle? strutStyle;
|
||||
|
||||
/// Defines how to measure the width of the rendered text.
|
||||
final TextWidthBasis? textWidthBasis;
|
||||
|
||||
// SelectableText.rich
|
||||
|
||||
/// Defines the focus for this widget.
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// Whether to show cursor
|
||||
final bool showCursor;
|
||||
|
||||
/// Whether this text field should focus itself if
|
||||
/// nothing else is already focused.
|
||||
final bool autofocus;
|
||||
|
||||
/// How thick the cursor will be
|
||||
final double cursorWidth;
|
||||
|
||||
/// How rounded the corners of the cursor should be
|
||||
final Radius? cursorRadius;
|
||||
|
||||
/// The color to use when painting the cursor
|
||||
final Color? cursorColor;
|
||||
|
||||
/// Determines the way that drag start behavior is handled
|
||||
final DragStartBehavior dragStartBehavior;
|
||||
|
||||
/// If true, then long-pressing this TextField will select text and show the cut/copy/paste menu,
|
||||
/// and tapping will move the text caret
|
||||
final bool enableInteractiveSelection;
|
||||
|
||||
/// Called when the user taps on this selectable text (not link)
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
final ScrollPhysics? scrollPhysics;
|
||||
|
||||
/// Defines how the paragraph will apply TextStyle.height to the ascent of
|
||||
/// the first line and descent of the last line.
|
||||
final TextHeightBehavior? textHeightBehavior;
|
||||
|
||||
/// How tall the cursor will be.
|
||||
final double? cursorHeight;
|
||||
|
||||
/// Optional delegate for building the text selection handles and toolbar.
|
||||
final TextSelectionControls? selectionControls;
|
||||
|
||||
/// Called when the user changes the selection of text (including the
|
||||
/// cursor location).
|
||||
final SelectionChangedCallback? onSelectionChanged;
|
||||
|
||||
final EditableTextContextMenuBuilder? contextMenuBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<LinkifyElement> elements = LinkifierUtil.linkify(text);
|
||||
return SelectableText.rich(
|
||||
buildTextSpan(
|
||||
elements,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.merge(style),
|
||||
onOpen: onOpen,
|
||||
linkStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.merge(style)
|
||||
.copyWith(
|
||||
color: Colors.blueAccent,
|
||||
decoration: TextDecoration.underline,
|
||||
)
|
||||
.merge(linkStyle),
|
||||
),
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
focusNode: focusNode,
|
||||
strutStyle: strutStyle,
|
||||
showCursor: showCursor,
|
||||
textScaleFactor: textScaleFactor,
|
||||
autofocus: autofocus,
|
||||
cursorWidth: cursorWidth,
|
||||
cursorRadius: cursorRadius,
|
||||
cursorColor: cursorColor,
|
||||
dragStartBehavior: dragStartBehavior,
|
||||
enableInteractiveSelection: enableInteractiveSelection,
|
||||
onTap: onTap,
|
||||
scrollPhysics: scrollPhysics,
|
||||
textWidthBasis: textWidthBasis,
|
||||
textHeightBehavior: textHeightBehavior,
|
||||
cursorHeight: cursorHeight,
|
||||
selectionControls: selectionControls,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
contextMenuBuilder: contextMenuBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _defaultContextMenuBuilder(
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) {
|
||||
return AdaptiveTextSelectionToolbar.editableText(
|
||||
editableTextState: editableTextState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LinkableSpan extends WidgetSpan {
|
||||
LinkableSpan({
|
||||
required MouseCursor mouseCursor,
|
||||
required InlineSpan inlineSpan,
|
||||
}) : super(
|
||||
child: MouseRegion(
|
||||
cursor: mouseCursor,
|
||||
child: Text.rich(
|
||||
inlineSpan,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Raw TextSpan builder for more control on the RichText
|
||||
TextSpan buildTextSpan(
|
||||
List<LinkifyElement> elements, {
|
||||
TextStyle? style,
|
||||
TextStyle? linkStyle,
|
||||
LinkCallback? onOpen,
|
||||
bool useMouseRegion = false,
|
||||
}) {
|
||||
return TextSpan(
|
||||
children: elements.map<InlineSpan>(
|
||||
(LinkifyElement element) {
|
||||
if (element is LinkableElement) {
|
||||
if (useMouseRegion) {
|
||||
return LinkableSpan(
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
inlineSpan: TextSpan(
|
||||
text: element.text,
|
||||
style: linkStyle,
|
||||
recognizer: onOpen != null
|
||||
? (TapGestureRecognizer()..onTap = () => onOpen(element))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return TextSpan(
|
||||
text: element.text,
|
||||
style: linkStyle,
|
||||
recognizer: onOpen != null
|
||||
? (TapGestureRecognizer()..onTap = () => onOpen(element))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (element is QuoteElement) {
|
||||
return TextSpan(
|
||||
text: element.text,
|
||||
style: style?.copyWith(
|
||||
backgroundColor: Palette.orangeAccent.withOpacity(0.3),
|
||||
),
|
||||
);
|
||||
} else if (element is EmphasisElement) {
|
||||
return TextSpan(
|
||||
text: element.text,
|
||||
style: style?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return TextSpan(
|
||||
text: element.text,
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
final RegExp _emphasisRegex = RegExp(
|
||||
r'\*(.*?)\*',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
class EmphasisLinkifier extends Linkifier {
|
||||
const EmphasisLinkifier();
|
||||
|
||||
@override
|
||||
List<LinkifyElement> parse(
|
||||
List<LinkifyElement> elements,
|
||||
LinkifyOptions options,
|
||||
) {
|
||||
final List<LinkifyElement> list = <LinkifyElement>[];
|
||||
|
||||
for (final LinkifyElement element in elements) {
|
||||
if (element is TextElement) {
|
||||
final RegExpMatch? match = _emphasisRegex.firstMatch(
|
||||
element.text.trimLeft(),
|
||||
);
|
||||
|
||||
if (element.text == '* * *' ||
|
||||
match == null ||
|
||||
match.group(0) == null ||
|
||||
match.group(1) == null) {
|
||||
list.add(element);
|
||||
} else {
|
||||
final String matchedText = match.group(1)!;
|
||||
final num pos =
|
||||
(element.text.indexOf(matchedText) - 1).clamp(0, double.infinity);
|
||||
final List<String> splitTexts = element.text.split(match.group(0)!);
|
||||
|
||||
int curPos = 0;
|
||||
bool added = false;
|
||||
|
||||
for (final String text in splitTexts) {
|
||||
list.addAll(parse(<LinkifyElement>[TextElement(text)], options));
|
||||
|
||||
curPos += text.length;
|
||||
|
||||
if (!added && curPos >= pos) {
|
||||
added = true;
|
||||
list.add(EmphasisElement(matchedText));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
list.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an element wrapped around '*'.
|
||||
@immutable
|
||||
class EmphasisElement extends LinkifyElement {
|
||||
EmphasisElement(super.text);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "EmphasisElement: '$text'";
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => equals(other);
|
||||
|
||||
@override
|
||||
bool equals(dynamic other) => other is EmphasisElement && super.equals(other);
|
||||
|
||||
@override
|
||||
int get hashCode => text.hashCode;
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export 'emphasis_linkifier.dart';
|
||||
export 'quote_linkifier.dart';
|
@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
final RegExp _quoteRegex = RegExp(
|
||||
r'(?=^> )(.*?)(?=\n|$)',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
class QuoteLinkifier extends Linkifier {
|
||||
const QuoteLinkifier();
|
||||
|
||||
@override
|
||||
List<LinkifyElement> parse(
|
||||
List<LinkifyElement> elements,
|
||||
LinkifyOptions options,
|
||||
) {
|
||||
final List<LinkifyElement> list = <LinkifyElement>[];
|
||||
|
||||
for (final LinkifyElement element in elements) {
|
||||
if (element is TextElement) {
|
||||
final RegExpMatch? match = _quoteRegex.firstMatch(
|
||||
element.text.trimLeft(),
|
||||
);
|
||||
|
||||
if (match == null) {
|
||||
list.add(element);
|
||||
} else {
|
||||
final String matchedText = match.group(0)!;
|
||||
final int pos = element.text.indexOf(matchedText);
|
||||
final List<String> splitTexts = element.text.split(matchedText);
|
||||
|
||||
int curPos = 0;
|
||||
bool added = false;
|
||||
|
||||
for (final String text in splitTexts) {
|
||||
list.addAll(parse(<TextElement>[TextElement(text)], options));
|
||||
curPos += text.length;
|
||||
if (!added && curPos >= pos) {
|
||||
added = true;
|
||||
list.add(QuoteElement(matchedText));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
list.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an element that starts with '>'.
|
||||
@immutable
|
||||
class QuoteElement extends LinkifyElement {
|
||||
QuoteElement(super.text);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "QuoteElement: '$text'";
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => equals(other);
|
||||
|
||||
@override
|
||||
bool equals(dynamic other) => other is QuoteElement && super.equals(other);
|
||||
|
||||
@override
|
||||
int get hashCode => text.hashCode;
|
||||
}
|
@ -1,18 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart' hide Badge;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/circle_tab_indicator.dart';
|
||||
import 'package:hacki/screens/widgets/onboarding_view.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class CustomTabBar extends StatefulWidget {
|
||||
const CustomTabBar({
|
||||
@ -27,11 +21,6 @@ class CustomTabBar extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CustomTabBarState extends State<CustomTabBar> {
|
||||
final Throttle featureDiscoveryDismissThrottle = Throttle(
|
||||
delay: _throttleDelay,
|
||||
);
|
||||
static const Duration _throttleDelay = Duration(seconds: 1);
|
||||
|
||||
late List<StoryType> tabs = context.read<TabCubit>().state.tabs;
|
||||
|
||||
int currentIndex = 0;
|
||||
@ -87,17 +76,8 @@ class _CustomTabBarState extends State<CustomTabBar> {
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: DescribedFeatureOverlay(
|
||||
onBackgroundTap: onFeatureDiscoveryDismissed,
|
||||
onDismiss: onFeatureDiscoveryDismissed,
|
||||
onComplete: () async {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
showOnboarding();
|
||||
return true;
|
||||
},
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
child: CustomDescribedFeatureOverlay(
|
||||
onComplete: showOnboarding,
|
||||
tapTarget: const Icon(
|
||||
Icons.person,
|
||||
size: TextDimens.pt16,
|
||||
@ -162,20 +142,4 @@ class _CustomTabBarState extends State<CustomTabBar> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> onFeatureDiscoveryDismissed() {
|
||||
featureDiscoveryDismissThrottle.run(() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
showSnackBar(content: 'Tap on icon to continue');
|
||||
});
|
||||
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
featureDiscoveryDismissThrottle.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
71
lib/screens/widgets/item_text.dart
Normal file
71
lib/screens/widgets/item_text.dart
Normal file
@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class ItemText extends StatelessWidget {
|
||||
const ItemText({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Item item;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PreferenceState prefState = context.read<PreferenceCubit>().state;
|
||||
final TextStyle style = TextStyle(
|
||||
fontSize: prefState.fontSize.fontSize,
|
||||
);
|
||||
final TextStyle linkStyle = TextStyle(
|
||||
fontSize: prefState.fontSize.fontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
color: Palette.orange,
|
||||
);
|
||||
if (item is Buildable) {
|
||||
return SelectableText.rich(
|
||||
buildTextSpan(
|
||||
(item as Buildable).elements,
|
||||
style: style,
|
||||
linkStyle: linkStyle,
|
||||
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
|
||||
),
|
||||
onTap: onTap,
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
contextMenuBuilder: (
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) =>
|
||||
contextMenuBuilder(
|
||||
context,
|
||||
editableTextState,
|
||||
item: item,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SelectableLinkify(
|
||||
text: item.text,
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
style: style,
|
||||
linkStyle: linkStyle,
|
||||
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
|
||||
onTap: onTap,
|
||||
contextMenuBuilder: (
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) =>
|
||||
contextMenuBuilder(
|
||||
context,
|
||||
editableTextState,
|
||||
item: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
|
@ -79,9 +79,8 @@ class StoryTile extends StatelessWidget {
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: RichText(
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
text: TextSpan(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: <TextSpan>[
|
||||
TextSpan(
|
||||
text: story.title,
|
||||
@ -105,6 +104,7 @@ class StoryTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -5,7 +5,10 @@ export 'comment_tile.dart';
|
||||
export 'countdown_reminder.dart';
|
||||
export 'custom_chip.dart';
|
||||
export 'custom_circular_progress_indicator.dart';
|
||||
export 'custom_described_feature_overlay.dart';
|
||||
export 'custom_linkify/custom_linkify.dart';
|
||||
export 'custom_tab_bar.dart';
|
||||
export 'item_text.dart';
|
||||
export 'items_list_view.dart';
|
||||
export 'link_preview/link_preview.dart';
|
||||
export 'offline_banner.dart';
|
||||
|
@ -6,7 +6,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/utils/html_util.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:path_provider_android/path_provider_android.dart';
|
||||
import 'package:path_provider_foundation/path_provider_foundation.dart';
|
||||
import 'package:shared_preferences_android/shared_preferences_android.dart';
|
||||
@ -34,14 +35,21 @@ abstract class Fetcher {
|
||||
static const int _subscriptionUpperLimit = 15;
|
||||
|
||||
static Future<void> fetchReplies() async {
|
||||
final PreferenceRepository preferenceRepository = PreferenceRepository();
|
||||
final Logger logger = Logger();
|
||||
final PreferenceRepository preferenceRepository =
|
||||
PreferenceRepository(logger: logger);
|
||||
|
||||
final AuthRepository authRepository = AuthRepository(
|
||||
preferenceRepository: preferenceRepository,
|
||||
logger: logger,
|
||||
);
|
||||
|
||||
final StoriesRepository storiesRepository = StoriesRepository();
|
||||
final SembastRepository sembastRepository = SembastRepository();
|
||||
|
||||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
final String? username = await authRepository.username;
|
||||
final List<int> unreadIds = await preferenceRepository.unreadCommentsIds;
|
||||
|
||||
@ -50,7 +58,7 @@ abstract class Fetcher {
|
||||
Comment? newReply;
|
||||
|
||||
await storiesRepository
|
||||
.fetchSubmitted(of: username)
|
||||
.fetchSubmitted(userId: username)
|
||||
.then((List<int>? submittedItems) async {
|
||||
if (submittedItems != null) {
|
||||
final List<int> subscribedItems = submittedItems.sublist(
|
||||
@ -59,9 +67,7 @@ abstract class Fetcher {
|
||||
);
|
||||
|
||||
for (final int id in subscribedItems) {
|
||||
await storiesRepository
|
||||
.fetchRawItemBy(id: id)
|
||||
.then((Item? item) async {
|
||||
await storiesRepository.fetchRawItem(id: id).then((Item? item) async {
|
||||
final List<int> kids = item?.kids ?? <int>[];
|
||||
final List<int> previousKids =
|
||||
(await sembastRepository.kids(of: id)) ?? <int>[];
|
||||
@ -76,7 +82,7 @@ abstract class Fetcher {
|
||||
if (unreadIds.contains(newCommentId)) continue;
|
||||
|
||||
await storiesRepository
|
||||
.fetchRawCommentBy(id: newCommentId)
|
||||
.fetchRawComment(id: newCommentId)
|
||||
.then((Comment? comment) async {
|
||||
final bool hasPushedBefore =
|
||||
await preferenceRepository.hasPushed(newReply!.id);
|
||||
|
@ -294,7 +294,7 @@ class WebAnalyzer {
|
||||
// Kids of stories from search results are always empty, so here we try
|
||||
// to fetch the story itself first and see if the kids are still empty.
|
||||
if (kids.isEmpty) {
|
||||
final Story? story = await storiesRepository.fetchStoryBy(storyId);
|
||||
final Story? story = await storiesRepository.fetchStory(id: storyId);
|
||||
|
||||
if (story == null) return null;
|
||||
|
||||
@ -304,7 +304,7 @@ class WebAnalyzer {
|
||||
}
|
||||
|
||||
final Comment? comment =
|
||||
await storiesRepository.fetchCommentBy(id: kids.first);
|
||||
await storiesRepository.fetchComment(id: kids.first);
|
||||
|
||||
return comment != null ? '${comment.by}: ${comment.text}' : null;
|
||||
}
|
||||
|
@ -4,9 +4,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart' show WebViewScreen;
|
||||
import 'package:hacki/screens/screens.dart'
|
||||
show ItemScreen, ItemScreenArgs, WebViewScreen;
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
@ -45,6 +48,11 @@ abstract class LinkUtil {
|
||||
return;
|
||||
}
|
||||
|
||||
if (link.isStoryLink) {
|
||||
_onStoryLinkTapped(link);
|
||||
return;
|
||||
}
|
||||
|
||||
Uri rinseLink(String link) {
|
||||
final RegExp regex = RegExp(RegExpConstants.linkSuffix);
|
||||
if (!link.contains('en.wikipedia.org') && link.contains(regex)) {
|
||||
@ -80,4 +88,23 @@ abstract class LinkUtil {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _onStoryLinkTapped(String link) async {
|
||||
final int? id = link.itemId;
|
||||
if (id != null) {
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchItem(id: id)
|
||||
.then((Item? item) {
|
||||
if (item != null) {
|
||||
HackiApp.navigatorKey.currentState!.pushNamed(
|
||||
ItemScreen.routeName,
|
||||
arguments: ItemScreenArgs(item: item),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
launch(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
29
lib/utils/linkifier_util.dart
Normal file
29
lib/utils/linkifier_util.dart
Normal file
@ -0,0 +1,29 @@
|
||||
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
abstract class LinkifierUtil {
|
||||
static List<LinkifyElement> linkify(String text) {
|
||||
const LinkifyOptions options = LinkifyOptions();
|
||||
const List<Linkifier> linkifiers = <Linkifier>[
|
||||
UrlLinkifier(),
|
||||
EmailLinkifier(),
|
||||
QuoteLinkifier(),
|
||||
EmphasisLinkifier(),
|
||||
];
|
||||
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
|
||||
|
||||
if (text.isEmpty) {
|
||||
return <LinkifyElement>[];
|
||||
}
|
||||
|
||||
if (linkifiers.isEmpty) {
|
||||
return list;
|
||||
}
|
||||
|
||||
for (final Linkifier linkifier in linkifiers) {
|
||||
list = linkifier.parse(list, options);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
export 'debouncer.dart';
|
||||
export 'html_util.dart';
|
||||
export 'link_util.dart';
|
||||
export 'linkifier_util.dart';
|
||||
export 'log_util.dart';
|
||||
export 'service_exception.dart';
|
||||
export 'throttle.dart';
|
||||
|
110
pubspec.lock
110
pubspec.lock
@ -37,10 +37,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611"
|
||||
sha256: "4cab82a83ffef80b262ddedf47a0a8e56ee6fbf7fe21e6e768b02792034dd440"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
version: "2.4.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -61,18 +61,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bloc
|
||||
sha256: bd4f8027bfa60d96c8046dec5ce74c463b2c918dce1b0d36593575995344534a
|
||||
sha256: "658a5ae59edcf1e58aac98b000a71c762ad8f46f1394c34a52050cafb3e11a80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
version: "8.1.1"
|
||||
bloc_test:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: bloc_test
|
||||
sha256: "622b97678bf8c06a94f4c26a89ee9ebf7319bf775383dee2233e86e1f94ee28d"
|
||||
sha256: ffbb60c17ee3d8e3784cb78071088e353199057233665541e8ac6cd438dca8ad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.0"
|
||||
version: "9.1.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -141,18 +141,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: "745ebcccb1ef73768386154428a55250bc8d44059c19fd27aecda2a6dc013a22"
|
||||
sha256: "8875e8ed511a49f030e313656154e4bbbcef18d68dfd32eb853fac10bce48e96"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "3.0.3"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_platform_interface
|
||||
sha256: b8795b9238bf83b64375f63492034cb3d8e222af4d9ce59dda085edf038fa06f
|
||||
sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
version: "1.2.4"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -165,18 +165,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "961c4aebd27917269b1896382c7cb1b1ba81629ba669ba09c27a7e5710ec9040"
|
||||
sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.2"
|
||||
version: "1.6.3"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: f71079978789bc2fe78d79227f1f8cfe195b31bbd8db2399b0d15a4b96fb843b
|
||||
sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+2"
|
||||
version: "0.3.3+4"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -275,10 +275,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_bloc
|
||||
sha256: "890c51c8007f0182360e523518a0c732efb89876cb4669307af7efada5b55557"
|
||||
sha256: "434951eea948dbe87f737b674281465f610b8259c16c097b8163ce138749a775"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.1"
|
||||
version: "8.1.2"
|
||||
flutter_blurhash:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -332,14 +332,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.7.2+3"
|
||||
flutter_linkify:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_linkify
|
||||
sha256: c89fe74de985ec22f23d3538d2249add085a4f37ac1c29fd79e1a207efb81d63
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -368,26 +360,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: f2afec1f1762c040a349ea2a588e32f442da5d0db3494a52a929a97c9e550bc5
|
||||
sha256: "98352186ee7ad3639ccc77ad7924b773ff6883076ab952437d20f18a61f0a7c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
version: "8.0.0"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "736436adaf91552433823f51ce22e098c2f0551db06b6596f58597a25b8ea797"
|
||||
sha256: "0912ae29a572230ad52d8a4697e5518d7f0f429052fd51df7e5a7952c7efe2a3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "1.1.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: ff0768a6700ea1d9620e03518e2e25eac86a8bd07ca3556e9617bfa5ace4bd00
|
||||
sha256: "083add01847fc1c80a07a08e1ed6927e9acd9618a35e330239d4422cd2a58c50"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
version: "3.0.0"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -408,10 +400,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: ca89c8059cf439985aa83c59619b3674c7ef6cc2e86943d169a7369d6a69cab5
|
||||
sha256: fc2910ec9b28d60598216c29ea763b3a96c401f0ce1d13cdf69ccb0e5c93c3ee
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
version: "2.0.0"
|
||||
flutter_siri_suggestions:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -442,10 +434,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: font_awesome_flutter
|
||||
sha256: "875dbb9ec1ad30d68102019ceb682760d06c72747c1c5b7885781b95f88569cc"
|
||||
sha256: "959ef4add147753f990b4a7c6cccb746d5792dbdc81b1cde99e62e7edb31b206"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.3.0"
|
||||
version: "10.4.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -535,10 +527,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hydrated_bloc
|
||||
sha256: "5871204f14b24638dc9d18d5b94cf22a66fc4be40756925cafff3a7553c7d7b7"
|
||||
sha256: eb92d88061b6b911c48779b08a91c8a9f3a3aa8475f80d9380045375d9876536
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
version: "9.1.0"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@ -569,7 +561,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.6.5"
|
||||
linkify:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: linkify
|
||||
sha256: bdfbdafec6cdc9cd0ebb333a868cafc046714ad508e48be8095208c54691d959
|
||||
@ -676,10 +668,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: f619162573096d428ccde2e33f92e05b5a179cd6f0e3120c1005f181bee8ed16
|
||||
sha256: "8df5ab0a481d7dc20c0e63809e90a588e496d276ba53358afc4c4443d0a00697"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "3.0.3"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -724,10 +716,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: ab0987bf95bc591da42dffb38c77398fc43309f0b9b894dcc5d6f40c4b26c379
|
||||
sha256: "2e32f1640f07caef0d3cb993680f181c79e54a3827b997d5ee221490d131fbd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.7"
|
||||
version: "2.1.8"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -829,10 +821,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: responsive_builder
|
||||
sha256: "0f082dff291f5ee4b4ef713d7d1e2a242b126204559024de07039aa7d9012aa5"
|
||||
sha256: "8eed603781a53fe1804a9ba50089ceb4882887f9c5b84ff139b03d8583a12fc9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0+1"
|
||||
version: "0.5.1"
|
||||
rxdart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -853,10 +845,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: e387077716f80609bb979cd199331033326033ecd1c8f200a90c5f57b1c9f55e
|
||||
sha256: "8c6892037b1824e2d7e8f59d54b3105932899008642e6372e5079c6939b4b625"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
version: "6.3.1"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -885,10 +877,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "1ffa239043ab8baf881ec3094a3c767af9d10399b2839020b9e4d44c0bb23951"
|
||||
sha256: "2b55c18636a4edc529fa5cd44c03d3f3100c00513f518c5127c951978efcccd0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.3"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1121,10 +1113,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: "698fa0b4392effdc73e9e184403b627362eb5fbf904483ac9defbb1c2191d809"
|
||||
sha256: e8f2efc804810c0f2f5b485f49e7942179f56eabcfe81dce3387fec4bb55876b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.8"
|
||||
version: "6.1.9"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1137,10 +1129,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: bb328b24d3bccc20bdf1024a0990ac4f869d57663660de9c936fb8c043edefe3
|
||||
sha256: "0a5af0aefdd8cf820dd739886efb1637f1f24489900204f50984634c07a54815"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.18"
|
||||
version: "6.1.0"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1201,10 +1193,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: very_good_analysis
|
||||
sha256: "4815adc7ded57657038d2bb2a7f332c50e3c8152f7d3c6acf8f6b7c0cc81e5e2"
|
||||
sha256: ebc48c51db35beeeec8c414e32f7bd78e612bd7f5992ccb0d46e19edaeb40b08
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "4.0.0+1"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1297,10 +1289,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: "9d97fa2bae0f1900553c48a2ef0aaa3864367fd7bb625d683c460754b691312c"
|
||||
sha256: "5f49a6e5fc59e21fcec5e1bbcd401afbee9792a24a4f3d9cef9b5bb0cd1e3767"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
version: "3.2.4"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1313,10 +1305,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: "523aff9168af9bb2170e4809e0499d7dee065c3919799fd3341d3e616c137960"
|
||||
sha256: "92e7e7fa468f1df597fb9d37bcf1f303175cbe147c4dbdf06ecc323d950116eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "3.0.5"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1358,5 +1350,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.18.0 <3.0.0"
|
||||
flutter: ">=3.7.3"
|
||||
dart: ">=2.19.0 <3.0.0"
|
||||
flutter: ">=3.7.5"
|
||||
|
67
pubspec.yaml
67
pubspec.yaml
@ -1,21 +1,21 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 1.0.10+88
|
||||
version: 1.2.1+94
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
flutter: "3.7.3"
|
||||
flutter: "3.7.5"
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^3.0.0
|
||||
badges: ^3.0.2
|
||||
bloc: ^8.1.0
|
||||
cached_network_image: ^3.2.1
|
||||
bloc: ^8.1.1
|
||||
cached_network_image: ^3.2.3
|
||||
clipboard: ^0.1.3
|
||||
collection: ^1.17.0
|
||||
connectivity_plus: ^3.0.2
|
||||
dio: ^4.0.4
|
||||
dio: ^4.0.6
|
||||
equatable: ^2.0.5
|
||||
fast_gbk: ^1.0.0
|
||||
feature_discovery:
|
||||
@ -24,53 +24,53 @@ dependencies:
|
||||
ref: flutter3_compatibility
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.1
|
||||
flutter_bloc: ^8.1.2
|
||||
flutter_cache_manager: ^3.3.0
|
||||
flutter_email_sender: ^5.2.0
|
||||
flutter_fadein: ^2.0.0
|
||||
flutter_feather_icons: 2.0.0+1
|
||||
flutter_inappwebview: ^5.7.2+3
|
||||
flutter_linkify: ^5.0.2
|
||||
flutter_local_notifications: ^13.0.0
|
||||
flutter_secure_storage: ^7.0.1
|
||||
flutter_secure_storage: ^8.0.0
|
||||
flutter_siri_suggestions: ^2.1.0
|
||||
flutter_slidable: ^2.0.0
|
||||
font_awesome_flutter: ^10.3.0
|
||||
gbk_codec: ^0.4.0
|
||||
get_it: 7.2.0
|
||||
hive: ^2.0.6
|
||||
html: ^0.15.0
|
||||
get_it: ^7.2.0
|
||||
hive: ^2.2.3
|
||||
html: ^0.15.1
|
||||
html_unescape: ^2.0.0
|
||||
http: ^0.13.3
|
||||
hydrated_bloc: ^9.0.0-dev.3
|
||||
http: ^0.13.5
|
||||
hydrated_bloc: ^9.1.0
|
||||
intl: ^0.18.0
|
||||
linkify: ^4.1.0
|
||||
logger: ^1.1.0
|
||||
package_info_plus: ^3.0.2
|
||||
package_info_plus: ^3.0.3
|
||||
path: ^1.8.2
|
||||
path_provider: ^2.0.8
|
||||
path_provider_android: ^2.0.8
|
||||
path_provider: ^2.0.12
|
||||
path_provider_android: ^2.0.22
|
||||
path_provider_foundation: ^2.1.1
|
||||
pull_to_refresh:
|
||||
git:
|
||||
url: https://github.com/livinglist/flutter_pulltorefresh
|
||||
ref: master
|
||||
receive_sharing_intent: ^1.4.5
|
||||
responsive_builder: ^0.5.0+1
|
||||
rxdart: ^0.27.3
|
||||
sembast: ^3.1.1+1
|
||||
share_plus: ^6.3.0
|
||||
responsive_builder: ^0.5.1
|
||||
rxdart: ^0.27.7
|
||||
sembast: ^3.4.0+6
|
||||
share_plus: ^6.3.1
|
||||
shared_preferences: ^2.0.17
|
||||
shared_preferences_android: ^2.0.15
|
||||
shared_preferences_foundation: ^2.1.2
|
||||
shared_preferences_foundation: ^2.1.3
|
||||
shimmer: ^2.0.0
|
||||
synced_shared_preferences:
|
||||
path: components/synced_shared_preferences
|
||||
tuple: ^2.0.0
|
||||
tuple: ^2.0.1
|
||||
universal_platform: ^1.0.0+1
|
||||
url_launcher: ^6.1.3
|
||||
url_launcher: ^6.1.9
|
||||
wakelock: ^0.6.1+2
|
||||
webview_flutter: ^4.0.2
|
||||
workmanager: ^0.5.0
|
||||
workmanager: ^0.5.1
|
||||
|
||||
dev_dependencies:
|
||||
bloc_test: ^9.1.0
|
||||
@ -81,7 +81,7 @@ dev_dependencies:
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
mocktail: ^0.3.0
|
||||
very_good_analysis: ^3.1.0
|
||||
very_good_analysis: ^4.0.0+1
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@ -90,4 +90,21 @@ flutter:
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||
fonts:
|
||||
- family: RobotoSlab
|
||||
fonts:
|
||||
- asset: assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
|
||||
- asset: assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
|
||||
weight: 700
|
||||
- family: Ubuntu
|
||||
fonts:
|
||||
- asset: assets/fonts/ubuntu/Ubuntu-Regular.ttf
|
||||
- asset: assets/fonts/ubuntu/Ubuntu-Bold.ttf
|
||||
weight: 700
|
||||
- family: UbuntuMono
|
||||
fonts:
|
||||
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
|
||||
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
|
||||
weight: 700
|
||||
|
||||
|
||||
|
Submodule submodules/flutter updated: 9944297138...c07f788888
@ -67,7 +67,7 @@ void main() {
|
||||
.thenAnswer((_) => Future<String?>.value(username));
|
||||
when(() => mockAuthRepository.password)
|
||||
.thenAnswer((_) => Future<String>.value(password));
|
||||
when(() => mockStoriesRepository.fetchUserBy(userId: username))
|
||||
when(() => mockStoriesRepository.fetchUser(id: username))
|
||||
.thenAnswer((_) => Future<User>.value(tUser));
|
||||
when(() => mockAuthRepository.loggedIn)
|
||||
.thenAnswer((_) => Future<bool>.value(false));
|
||||
@ -91,7 +91,7 @@ void main() {
|
||||
verify: (_) {
|
||||
verify(() => mockAuthRepository.loggedIn).called(2);
|
||||
verifyNever(() => mockAuthRepository.username);
|
||||
verifyNever(() => mockStoriesRepository.fetchUserBy(userId: username));
|
||||
verifyNever(() => mockStoriesRepository.fetchUser(id: username));
|
||||
},
|
||||
);
|
||||
|
||||
@ -154,8 +154,7 @@ void main() {
|
||||
password: password,
|
||||
),
|
||||
).called(1);
|
||||
verify(() => mockStoriesRepository.fetchUserBy(userId: username))
|
||||
.called(1);
|
||||
verify(() => mockStoriesRepository.fetchUser(id: username)).called(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
Reference in New Issue
Block a user