Compare commits

..

21 Commits

Author SHA1 Message Date
b9636349d7 updated fastlane. 2022-03-08 19:02:47 -08:00
2028015977 fixed UI. 2022-03-08 19:01:39 -08:00
b0225303f3 removed pagination. 2022-03-08 18:46:50 -08:00
cf366927bc improved UI. 2022-03-08 18:34:24 -08:00
26ffe7535f upgraded packages. 2022-03-08 17:40:12 -08:00
b1e3ba5b0a changed scroll physics. 2022-03-08 17:35:17 -08:00
c71952c098 added pagination to story screen. 2022-03-08 17:18:31 -08:00
5fdfd42fda updated fastlane. 2022-03-07 17:00:44 -08:00
823021c673 fixed date time issue. 2022-03-07 16:51:06 -08:00
55f5e33210 fixed date time issue. 2022-03-07 16:47:22 -08:00
ee79bbe5c3 updated README.md 2022-03-07 01:22:06 -08:00
cf7f64e541 Merge pull request #9 from Livinglist/v0.2.0
v0.2.0
2022-03-07 01:19:38 -08:00
72ffc9d732 improved offline mode. 2022-03-07 01:13:30 -08:00
cc071b15c2 hide pinned stories in offline mode. 2022-03-07 00:46:18 -08:00
29cd5661f4 fixed delete all func. 2022-03-07 00:35:15 -08:00
3169944223 updated fastlane. 2022-03-07 00:24:53 -08:00
5e7d27e32f fixed profile screen about dialog. 2022-03-07 00:23:43 -08:00
8570968948 fixed scrolling animation. 2022-03-07 00:14:34 -08:00
27bbe23d0c fixed issues. 2022-03-06 22:29:19 -08:00
036fa2bbeb added offline mode. 2022-03-06 21:03:10 -08:00
c7cd8a918e updated fastlane. 2022-03-02 20:38:53 -08:00
39 changed files with 1417 additions and 893 deletions

View File

@ -26,6 +26,7 @@ Features:
- Vote on comments or stories.
- Pin stories to the top of home page.
- Get in-app notification when there is new reply to your stories or comments.
- Download stories for offline reading.
- And more...
@ -41,6 +42,7 @@ Features:
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/152301588-070ded9a-117a-48d8-bad4-9f77d54d98df.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/152301590-5383200c-db73-487d-8742-57ccbbbf04e8.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/153973720-8a6aad44-7df3-4deb-8465-8c88b5e5f587.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/153973715-a33018d2-d3b1-4bfa-be39-56f5e3c4830b.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/157003000-15671cf0-9470-4a89-b123-f63ca70a970f.png">
</p>

View File

@ -0,0 +1,3 @@
- Tapping on comment in notification or history screen will now lead you directly to the comment.
- Fixed the bug where reply box cannot be expanded in editing mode.
- Fixed inconsistent font size in history screen.

View File

@ -0,0 +1 @@
- Added offline mode.

View File

@ -0,0 +1,2 @@
- Added offline mode.
- Bugfixes.

View File

@ -0,0 +1,2 @@
- Added offline mode.
- Bugfixes.

View File

@ -0,0 +1,2 @@
- Added offline mode.
- Bugfixes.

View File

@ -10,4 +10,5 @@ Features:
- Vote on comments or stories.
- Pin stories to the top of home page.
- Get in-app notification when there is new reply to your stories or comments.
- Download stories for offline reading.
- And more...

View File

@ -356,7 +356,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -365,7 +365,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.9;
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -491,7 +491,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -500,7 +500,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.9;
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -520,7 +520,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -529,7 +529,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.9;
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -40,7 +40,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((loggedIn) async {
if (loggedIn) {
final username = await _authRepository.username;
final user = await _storiesRepository.fetchUserById(userId: username!);
final user = await _storiesRepository.fetchUserBy(userId: username!);
emit(state.copyWith(
isLoggedIn: true,
@ -73,8 +73,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
username: event.username, password: event.password);
if (successful) {
final user =
await _storiesRepository.fetchUserById(userId: event.username);
final user = await _storiesRepository.fetchUserBy(userId: event.username);
emit(state.copyWith(
user: user,
isLoggedIn: true,

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
@ -10,8 +12,10 @@ part 'stories_state.dart';
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesBloc({
CacheRepository? cacheRepository,
StoriesRepository? storiesRepository,
}) : _storiesRepository =
}) : _cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
super(const StoriesState.init()) {
on<StoriesInitialize>(onInitialize);
@ -19,43 +23,80 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
on<StoriesLoadMore>(onLoadMore);
on<StoryLoaded>(onStoryLoaded);
on<StoriesLoaded>(onStoriesLoaded);
on<StoriesDownload>(onDownload);
on<StoriesExitOffline>(onExitOffline);
add(StoriesInitialize());
}
final CacheRepository _cacheRepository;
final StoriesRepository _storiesRepository;
static const _pageSize = 20;
Future<void> loadTopStories(
Future<void> loadStories(
{required StoryType of, required Emitter<StoriesState> emit}) async {
final ids = await _storiesRepository.fetchStoryIds(of: of);
emit(state
.copyWithStoryIdsUpdated(of: of, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0));
_storiesRepository
.fetchStoriesStream(ids: ids.sublist(0, 20))
.listen((story) {
add(StoryLoaded(story: story, type: of));
}).onDone(() {
add(StoriesLoaded(type: of));
});
if (state.offlineReading) {
final ids = await _cacheRepository.getCachedStoryIds(of: of);
emit(state
.copyWithStoryIdsUpdated(of: of, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0));
_cacheRepository
.getCachedStoriesStream(
ids: ids.sublist(0, min(ids.length, _pageSize)))
.listen((story) {
add(StoryLoaded(story: story, type: of));
}).onDone(() {
add(StoriesLoaded(type: of));
});
} else {
final ids = await _storiesRepository.fetchStoryIds(of: of);
emit(state
.copyWithStoryIdsUpdated(of: of, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0));
_storiesRepository
.fetchStoriesStream(ids: ids.sublist(0, _pageSize))
.listen((story) {
add(StoryLoaded(story: story, type: of));
}).onDone(() {
add(StoriesLoaded(type: of));
});
}
}
Future<void> onInitialize(
StoriesInitialize event, Emitter<StoriesState> emit) async {
await loadTopStories(of: StoryType.top, emit: emit);
await loadTopStories(of: StoryType.latest, emit: emit);
await loadTopStories(of: StoryType.ask, emit: emit);
await loadTopStories(of: StoryType.show, emit: emit);
await loadTopStories(of: StoryType.jobs, emit: emit);
final hasCachedStories = await _cacheRepository.hasCachedStories;
emit(state.copyWith(offlineReading: hasCachedStories));
await loadStories(of: StoryType.top, emit: emit);
await loadStories(of: StoryType.latest, emit: emit);
await loadStories(of: StoryType.ask, emit: emit);
await loadStories(of: StoryType.show, emit: emit);
await loadStories(of: StoryType.jobs, emit: emit);
}
Future<void> onRefresh(
StoriesRefresh event, Emitter<StoriesState> emit) async {
emit(state.copyWithRefreshed(of: event.type));
await loadTopStories(of: event.type, emit: emit);
emit(state.copyWithStatusUpdated(
of: event.type,
to: StoriesStatus.loading,
));
if (state.offlineReading) {
emit(state.copyWithStatusUpdated(
of: event.type,
to: StoriesStatus.loaded,
));
} else {
emit(state.copyWithRefreshed(of: event.type));
await loadStories(of: event.type, emit: emit);
}
}
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
emit(state.copyWithStatusUpdated(
of: event.type,
to: StoriesStatus.loading,
));
final currentPage = state.currentPageByType[event.type]!;
final len = state.storyIdsByType[event.type]!.length;
emit(state.copyWithCurrentPageUpdated(of: event.type, to: currentPage + 1));
@ -67,18 +108,37 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
upper = len;
}
_storiesRepository
.fetchStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
))
.listen((story) {
add(StoryLoaded(
story: story,
type: event.type,
));
});
if (state.offlineReading) {
_cacheRepository
.getCachedStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
))
.listen((story) {
add(StoryLoaded(
story: story,
type: event.type,
));
}).onDone(() {
add(StoriesLoaded(type: event.type));
});
} else {
_storiesRepository
.fetchStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
))
.listen((story) {
add(StoryLoaded(
story: story,
type: event.type,
));
}).onDone(() {
add(StoriesLoaded(type: event.type));
});
}
} else {
emit(state.copyWithStatusUpdated(
of: event.type, to: StoriesStatus.loaded));
@ -87,17 +147,66 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
void onStoryLoaded(StoryLoaded event, Emitter<StoriesState> emit) {
emit(state.copyWithStoryAdded(of: event.type, story: event.story));
if (state.storiesByType[event.type]!.length % _pageSize == 0) {
emit(
state.copyWithStatusUpdated(
of: event.type,
to: StoriesStatus.loaded,
),
);
}
}
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
emit(state.copyWithStatusUpdated(of: event.type, to: StoriesStatus.loaded));
}
Future<void> onDownload(
StoriesDownload event, Emitter<StoriesState> emit) async {
emit(state.copyWith(
downloadStatus: StoriesDownloadStatus.downloading,
));
await _cacheRepository.deleteAllStoryIds();
await _cacheRepository.deleteAllStories();
await _cacheRepository.deleteAllComments();
final topIds = await _storiesRepository.fetchStoryIds(of: StoryType.top);
final newIds = await _storiesRepository.fetchStoryIds(of: StoryType.latest);
final askIds = await _storiesRepository.fetchStoryIds(of: StoryType.ask);
final showIds = await _storiesRepository.fetchStoryIds(of: StoryType.show);
final jobIds = await _storiesRepository.fetchStoryIds(of: StoryType.jobs);
await _cacheRepository.cacheStoryIds(of: StoryType.top, ids: topIds);
await _cacheRepository.cacheStoryIds(of: StoryType.latest, ids: newIds);
await _cacheRepository.cacheStoryIds(of: StoryType.ask, ids: askIds);
await _cacheRepository.cacheStoryIds(of: StoryType.show, ids: showIds);
await _cacheRepository.cacheStoryIds(of: StoryType.jobs, ids: jobIds);
final allIds = [...topIds, ...newIds, ...askIds, ...showIds, ...jobIds];
try {
_storiesRepository.fetchStoriesStream(ids: allIds).listen((story) async {
if (story.kids.isNotEmpty) {
await _cacheRepository.cacheStory(story: story);
_storiesRepository
.fetchAllChildrenComments(ids: story.kids)
.listen((comment) async {
if (comment != null) {
await _cacheRepository.cacheComment(comment: comment);
}
});
}
}).onDone(() {
emit(state.copyWith(
downloadStatus: StoriesDownloadStatus.finished,
));
});
} catch (_) {
emit(state.copyWith(
downloadStatus: StoriesDownloadStatus.failure,
));
}
}
Future<void> onExitOffline(
StoriesExitOffline event, Emitter<StoriesState> emit) async {
await _cacheRepository.deleteAllStoryIds();
await _cacheRepository.deleteAllStories();
await _cacheRepository.deleteAllComments();
emit(state.copyWith(offlineReading: false));
add(StoriesInitialize());
}
}

View File

@ -37,6 +37,16 @@ class StoriesLoadMore extends StoriesEvent {
List<Object?> get props => [type];
}
class StoriesDownload extends StoriesEvent {
@override
List<Object?> get props => [];
}
class StoriesExitOffline extends StoriesEvent {
@override
List<Object?> get props => [];
}
class StoryLoaded extends StoriesEvent {
StoryLoaded({required this.story, required this.type});

View File

@ -1,6 +1,17 @@
part of 'stories_bloc.dart';
enum StoriesStatus { loading, loaded }
enum StoriesStatus {
initial,
loading,
loaded,
}
enum StoriesDownloadStatus {
initial,
downloading,
finished,
failure,
}
class StoriesState extends Equatable {
const StoriesState({
@ -8,6 +19,8 @@ class StoriesState extends Equatable {
required this.storyIdsByType,
required this.statusByType,
required this.currentPageByType,
required this.offlineReading,
required this.downloadStatus,
});
const StoriesState.init({
@ -26,11 +39,11 @@ class StoriesState extends Equatable {
StoryType.jobs: [],
},
this.statusByType = const <StoryType, StoriesStatus>{
StoryType.top: StoriesStatus.loaded,
StoryType.latest: StoriesStatus.loaded,
StoryType.ask: StoriesStatus.loaded,
StoryType.show: StoriesStatus.loaded,
StoryType.jobs: StoriesStatus.loaded,
StoryType.top: StoriesStatus.initial,
StoryType.latest: StoriesStatus.initial,
StoryType.ask: StoriesStatus.initial,
StoryType.show: StoriesStatus.initial,
StoryType.jobs: StoriesStatus.initial,
},
this.currentPageByType = const <StoryType, int>{
StoryType.top: 0,
@ -39,24 +52,31 @@ class StoriesState extends Equatable {
StoryType.show: 0,
StoryType.jobs: 0,
},
});
}) : offlineReading = false,
downloadStatus = StoriesDownloadStatus.initial;
final Map<StoryType, List<Story>> storiesByType;
final Map<StoryType, List<int>> storyIdsByType;
final Map<StoryType, StoriesStatus> statusByType;
final Map<StoryType, int> currentPageByType;
final StoriesDownloadStatus downloadStatus;
final bool offlineReading;
StoriesState copyWith({
Map<StoryType, List<Story>>? storiesByType,
Map<StoryType, List<int>>? storyIdsByType,
Map<StoryType, StoriesStatus>? statusByType,
Map<StoryType, int>? currentPageByType,
StoriesDownloadStatus? downloadStatus,
bool? offlineReading,
}) {
return StoriesState(
storiesByType: storiesByType ?? this.storiesByType,
storyIdsByType: storyIdsByType ?? this.storyIdsByType,
statusByType: statusByType ?? this.statusByType,
currentPageByType: currentPageByType ?? this.currentPageByType,
offlineReading: offlineReading ?? this.offlineReading,
downloadStatus: downloadStatus ?? this.downloadStatus,
);
}
@ -71,6 +91,8 @@ class StoriesState extends Equatable {
storyIdsByType: storyIdsByType,
statusByType: statusByType,
currentPageByType: currentPageByType,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -85,6 +107,8 @@ class StoriesState extends Equatable {
storyIdsByType: newMap,
statusByType: statusByType,
currentPageByType: currentPageByType,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -99,6 +123,8 @@ class StoriesState extends Equatable {
storyIdsByType: storyIdsByType,
statusByType: newMap,
currentPageByType: currentPageByType,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -113,6 +139,8 @@ class StoriesState extends Equatable {
storyIdsByType: storyIdsByType,
statusByType: statusByType,
currentPageByType: newMap,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -130,6 +158,8 @@ class StoriesState extends Equatable {
storyIdsByType: newStoryIdsMap,
statusByType: newStatusMap,
currentPageByType: newCurrentPageMap,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -139,5 +169,7 @@ class StoriesState extends Equatable {
storyIdsByType,
statusByType,
currentPageByType,
offlineReading,
downloadStatus,
];
}

View File

@ -3,6 +3,12 @@ class Constants {
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45';
static const String hackerNewsLogoLink =
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
static const String portfolioLink = 'https://livinglist.github.io';
static const String githubLink = 'https://github.com/Livinglist/Hacki';
static const String appStoreLink =
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
static const String googlePlayLink =
'https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US';
static const String hackerNewsLogoPath = 'images/hacker_news_logo.png';
static const String hackiIconPath = 'images/hacki_icon.png';

View File

@ -22,11 +22,11 @@ class CacheCubit extends Cubit<CacheState> {
void markStoryAsRead(int id) {
emit(state.copyWithStoryMarkedAsRead(id: id));
_cacheRepository.cacheReadStory(id: id);
_cacheRepository.cacheReadStoryId(id: id);
}
void deleteAll() {
void deleteAllReadStoryIds() {
emit(CacheState.init());
_cacheRepository.deleteAll();
_cacheRepository.deleteAllReadStoryIds();
}
}

View File

@ -3,39 +3,46 @@ import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/cache_service.dart';
import 'package:hacki/services/services.dart';
part 'comments_state.dart';
class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
CommentsCubit({
CacheService? cacheService,
CacheRepository? cacheRepository,
StoriesRepository? storiesRepository,
required bool offlineReading,
required T item,
}) : _cacheService = cacheService ?? locator.get<CacheService>(),
_cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
super(CommentsState.init());
super(CommentsState.init(offlineReading: offlineReading, item: item));
final CacheService _cacheService;
final CacheRepository _cacheRepository;
final StoriesRepository _storiesRepository;
Future<void> init(
T item, {
Future<void> init({
bool onlyShowTargetComment = false,
Comment? targetComment,
}) async {
if (onlyShowTargetComment) {
emit(state.copyWith(
item: item,
comments: targetComment != null ? [targetComment] : [],
onlyShowTargetComment: true,
));
return;
}
if (item is Story) {
final story = item;
final updatedStory = await _storiesRepository.fetchStoryById(story.id);
emit(state.copyWith(status: CommentsStatus.loading));
if (state.item is Story) {
final story = state.item;
final updatedStory = state.offlineReading
? story
: await _storiesRepository.fetchStoryBy(story.id);
emit(state.copyWith(item: updatedStory));
@ -45,21 +52,27 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
emit(state.copyWith(
comments: List.from(state.comments)..add(cachedComment)));
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
if (state.offlineReading) {
await _cacheRepository
.getCachedComment(id: id)
.then(_onCommentFetched);
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
}
}
}
emit(state.copyWith(
status: CommentsStatus.loaded,
));
if (!isClosed) {
emit(state.copyWith(
status: CommentsStatus.loaded,
));
}
} else {
final comment = item;
final comment = state.item as Comment;
emit(state.copyWith(
item: item,
collapsed: _cacheService.isCollapsed(item.id),
collapsed: _cacheService.isCollapsed(state.item.id),
));
for (final id in comment.kids) {
@ -68,23 +81,43 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
emit(state.copyWith(
comments: List.from(state.comments)..add(cachedComment)));
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
if (state.offlineReading) {
await _cacheRepository
.getCachedComment(id: id)
.then(_onCommentFetched);
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
}
}
}
emit(state.copyWith(
status: CommentsStatus.loaded,
));
if (!isClosed) {
emit(state.copyWith(
status: CommentsStatus.loaded,
));
}
}
}
Future<void> refresh() async {
emit(state.copyWith(status: CommentsStatus.loading, comments: []));
final offlineReading = await _cacheRepository.hasCachedStories;
if (offlineReading) {
emit(state.copyWith(
status: CommentsStatus.loaded,
));
return;
}
emit(state.copyWith(
status: CommentsStatus.loading,
comments: [],
));
final story = (state.item as Story?)!;
final updatedStory = await _storiesRepository.fetchStoryById(story.id);
final updatedStory = await _storiesRepository.fetchStoryBy(story.id);
for (final id in updatedStory.kids) {
final cachedComment = _cacheService.getComment(id);
@ -92,7 +125,17 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
emit(state.copyWith(
comments: List.from(state.comments)..add(cachedComment)));
} else {
await _storiesRepository.fetchCommentBy(id: id).then(_onCommentFetched);
final offlineReading = await _cacheRepository.hasCachedStories;
if (offlineReading) {
await _cacheRepository
.getCachedComment(id: id)
.then(_onCommentFetched);
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
}
}
}
@ -103,7 +146,7 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
}
void collapse() {
_cacheService.updateCollapsedComments(state.item!.id);
_cacheService.updateCollapsedComments(state.item.id);
emit(state.copyWith(collapsed: !state.collapsed));
}
@ -111,12 +154,13 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
emit(state.copyWith(
onlyShowTargetComment: false,
comments: [],
item: item,
));
init(item);
init();
}
void _onCommentFetched(Comment? comment) {
if (comment != null) {
if (comment != null && !isClosed) {
_cacheService.cacheComment(comment);
emit(state.copyWith(comments: List.from(state.comments)..add(comment)));
}

View File

@ -14,20 +14,23 @@ class CommentsState extends Equatable {
required this.status,
required this.collapsed,
required this.onlyShowTargetComment,
required this.offlineReading,
});
CommentsState.init()
: item = null,
comments = [],
CommentsState.init({
required this.offlineReading,
required this.item,
}) : comments = [],
status = CommentsStatus.init,
collapsed = false,
onlyShowTargetComment = false;
final Item? item;
final Item item;
final List<Comment> comments;
final CommentsStatus status;
final bool collapsed;
final bool onlyShowTargetComment;
final bool offlineReading;
CommentsState copyWith({
Item? item,
@ -35,6 +38,7 @@ class CommentsState extends Equatable {
CommentsStatus? status,
bool? collapsed,
bool? onlyShowTargetComment,
bool? offlineReading,
}) {
return CommentsState(
item: item ?? this.item,
@ -43,6 +47,7 @@ class CommentsState extends Equatable {
collapsed: collapsed ?? this.collapsed,
onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading,
);
}
@ -53,5 +58,6 @@ class CommentsState extends Equatable {
status,
collapsed,
onlyShowTargetComment,
offlineReading,
];
}

View File

@ -66,7 +66,7 @@ class FavCubit extends Cubit<FavState> {
),
);
final story = await _storiesRepository.fetchStoryById(id);
final story = await _storiesRepository.fetchStoryBy(id);
emit(state.copyWith(
favStories: List<Story>.from(state.favStories)..insert(0, story)));

View File

@ -6,7 +6,12 @@ extension DateTimeExtension on DateTime {
final gap = now.year - year;
return '$gap year${gap == 1 ? '' : 's'} ago';
} else if (diff.inDays > 30) {
final gap = (now.month - month).clamp(1, 12);
var gap = now.month - month;
if (gap == 0) {
gap = 1;
} else if (gap < 0) {
gap = now.month + (12 - month);
}
return '$gap month${gap == 1 ? '' : 's'} ago';
} else if (diff.inDays >= 1) {
if (diff.inHours <= 24) {

View File

@ -79,6 +79,23 @@ class Story extends Item {
String get postedDate =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
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,
};
}
@override
List<Object?> get props => [
id,

View File

@ -1,31 +1,124 @@
import 'package:hacki/models/models.dart';
import 'package:hive/hive.dart';
class CacheRepository {
CacheRepository({Future<Box<bool>>? box})
: _box = box ?? Hive.openBox<bool>(_boxName);
CacheRepository({
Future<Box<bool>>? readStoryIdBox,
Future<Box<List<int>>>? storyIdBox,
Future<Box<Map<dynamic, dynamic>>>? storyBox,
Future<LazyBox<Map<dynamic, dynamic>>>? commentBox,
}) : _readStoryIdBox =
readStoryIdBox ?? Hive.openBox<bool>(_readStoryIdBoxName),
_storyIdBox = storyIdBox ?? Hive.openBox<List<int>>(_storyIdBoxName),
_storyBox =
storyBox ?? Hive.openBox<Map<dynamic, dynamic>>(_storyBoxName),
_commentBox = commentBox ??
Hive.openLazyBox<Map<dynamic, dynamic>>(_commentBoxName);
static const _boxName = 'cacheBox';
final Future<Box<bool>> _box;
static const _readStoryIdBoxName = 'readStoryIdBox';
static const _storyIdBoxName = 'storyIdBox';
static const _storyBoxName = 'storyBox';
static const _commentBoxName = 'commentBox';
final Future<Box<bool>> _readStoryIdBox;
final Future<Box<List<int>>> _storyIdBox;
final Future<Box<Map<dynamic, dynamic>>> _storyBox;
final Future<LazyBox<Map<dynamic, dynamic>>> _commentBox;
Future<bool> get hasCachedStories => _storyBox.then((box) => box.isNotEmpty);
Future<bool> wasRead({required int id}) async {
final box = await _box;
final box = await _readStoryIdBox;
final val = box.get(id.toString());
return val != null;
}
Future<void> cacheReadStory({required int id}) async {
final box = await _box;
Future<void> cacheReadStoryId({required int id}) async {
final box = await _readStoryIdBox;
return box.put(id.toString(), true);
}
Future<List<int>> getAllReadStoriesIds() async {
final box = await _box;
final box = await _readStoryIdBox;
final allReads = box.keys.cast<String>().map(int.parse).toList();
return allReads;
}
Future<int> deleteAll() async {
final box = await _box;
Future<void> cacheStoryIds(
{required StoryType of, required List<int> ids}) async {
final box = await _storyIdBox;
return box.put(of.name, ids);
}
Future<void> cacheStory({required Story story}) async {
final box = await _storyBox;
return box.put(story.id.toString(), story.toJson());
}
Future<List<int>> getCachedStoryIds({required StoryType of}) async {
final box = await _storyIdBox;
final ids = box.get(of.name);
return ids ?? [];
}
Stream<Story> getCachedStoriesStream({required List<int> ids}) async* {
final box = await _storyBox;
for (final id in ids) {
final json = box.get(id.toString());
if (json == null) {
continue;
}
final story = Story.fromJson(json.cast<String, dynamic>());
yield story;
}
return;
}
Future<Story?> getCachedStory({required int id}) async {
final box = await _storyBox;
final json = box.get(id.toString());
if (json == null) {
return null;
}
final story = Story.fromJson(json.cast<String, dynamic>());
return story;
}
Future<void> cacheComment({required Comment comment}) async {
final box = await _commentBox;
return box.put(comment.id.toString(), comment.toJson());
}
Future<Comment?> getCachedComment({required int id}) async {
final box = await _commentBox;
final json = await box.get(id.toString());
if (json == null) {
return null;
}
final comment = Comment.fromJson(json.cast<String, dynamic>());
return comment;
}
Future<int> deleteAllReadStoryIds() async {
final box = await _readStoryIdBox;
return box.clear();
}
Future<int> deleteAllStoryIds() async {
final box = await _storyIdBox;
return box.clear();
}
Future<int> deleteAllStories() async {
final box = await _storyBox;
return box.clear();
}
Future<int> deleteAllComments() async {
final box = await _commentBox;
return box.clear();
}
}

View File

@ -12,7 +12,7 @@ class StoriesRepository {
final FirebaseClient _firebaseClient;
static const _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
Future<User> fetchUserById({required String userId}) async {
Future<User> fetchUserBy({required String userId}) async {
final user = await _firebaseClient
.get('${_baseUrl}user/$userId.json')
.then((dynamic val) {
@ -48,7 +48,7 @@ class StoriesRepository {
return ids;
}
Future<Story> fetchStoryById(int id) async {
Future<Story> fetchStoryBy(int id) async {
final story = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic val) {
@ -192,6 +192,16 @@ class StoriesRepository {
return Tuple2<Story, List<Comment>>(item as Story, parentComments);
}
Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* {
for (final id in ids) {
final comment = await fetchCommentBy(id: id);
if (comment != null) {
yield comment;
yield* fetchAllChildrenComments(ids: comment.kids);
}
}
}
static String _parseHtml(String text) {
return HtmlUnescape()
.convert(text)

View File

@ -129,298 +129,176 @@ class _HomeScreenState extends State<HomeScreen>
},
);
return BlocConsumer<StoriesBloc, StoriesState>(
listener: (context, state) {
if (state.statusByType[StoryType.top] == StoriesStatus.loaded) {
refreshControllerTop
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
if (state.statusByType[StoryType.latest] == StoriesStatus.loaded) {
refreshControllerNew
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
if (state.statusByType[StoryType.ask] == StoriesStatus.loaded) {
refreshControllerAsk
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
if (state.statusByType[StoryType.show] == StoriesStatus.loaded) {
refreshControllerShow
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
if (state.statusByType[StoryType.jobs] == StoriesStatus.loaded) {
refreshControllerJobs
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
},
builder: (context, state) {
return BlocBuilder<CacheCubit, CacheState>(
builder: (context, cacheState) {
return DefaultTabController(
length: 6,
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: PreferredSize(
preferredSize: const Size(0, 48),
child: Column(
children: [
SizedBox(
height: MediaQuery.of(context).padding.top,
return BlocBuilder<CacheCubit, CacheState>(
builder: (context, cacheState) {
return DefaultTabController(
length: 6,
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: PreferredSize(
preferredSize: const Size(0, 48),
child: Column(
children: [
SizedBox(
height: MediaQuery.of(context).padding.top,
),
TabBar(
isScrollable: true,
controller: tabController,
indicatorColor: Colors.orange,
tabs: [
Tab(
child: Text(
'TOP',
style: TextStyle(
fontSize: 14,
color: currentIndex == 0
? Colors.orange
: Colors.grey,
),
),
),
TabBar(
isScrollable: true,
controller: tabController,
indicatorColor: Colors.orange,
tabs: [
Tab(
child: Text(
'TOP',
style: TextStyle(
fontSize: 14,
color: currentIndex == 0
? Colors.orange
: Colors.grey,
),
),
Tab(
child: Text(
'NEW',
style: TextStyle(
fontSize: 14,
color: currentIndex == 1
? Colors.orange
: Colors.grey,
),
Tab(
child: Text(
'NEW',
style: TextStyle(
fontSize: 14,
color: currentIndex == 1
? Colors.orange
: Colors.grey,
),
),
),
),
Tab(
child: Text(
'ASK',
style: TextStyle(
fontSize: 14,
color: currentIndex == 2
? Colors.orange
: Colors.grey,
),
Tab(
child: Text(
'ASK',
style: TextStyle(
fontSize: 14,
color: currentIndex == 2
? Colors.orange
: Colors.grey,
),
),
),
),
Tab(
child: Text(
'SHOW',
style: TextStyle(
fontSize: 13,
color: currentIndex == 3
? Colors.orange
: Colors.grey,
),
Tab(
child: Text(
'SHOW',
style: TextStyle(
fontSize: 13,
color: currentIndex == 3
? Colors.orange
: Colors.grey,
),
),
),
),
Tab(
child: Text(
'JOBS',
style: TextStyle(
fontSize: 14,
color: currentIndex == 4
? Colors.orange
: Colors.grey,
),
Tab(
child: Text(
'JOBS',
style: TextStyle(
fontSize: 14,
color: currentIndex == 4
? Colors.orange
: Colors.grey,
),
),
),
),
Tab(
child: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.person,
size: 16,
color: Colors.white,
),
Tab(
child: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.person,
size: 16,
color: Colors.white,
),
featureId: Constants.featureLogIn,
title: const Text(''),
description: const Text(
'Log in using your Hacker News account '
'to check out stories and comments you have '
'posted in the past, and get in-app '
'notification when there is new reply to '
'your comments or stories.',
style: TextStyle(fontSize: 16),
),
child: BlocBuilder<NotificationCubit,
NotificationState>(
builder: (context, state) {
if (state.unreadCommentsIds.isEmpty) {
return Icon(
Icons.person,
size: 16,
color: currentIndex == 5
? Colors.orange
: Colors.grey,
);
} else {
return Badge(
borderRadius:
BorderRadius.circular(100),
badgeContent: Container(
height: 3,
width: 3,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white),
),
child: Icon(
Icons.person,
size: 16,
color: currentIndex == 5
? Colors.orange
: Colors.grey,
),
);
}
},
),
),
featureId: Constants.featureLogIn,
title: const Text(''),
description: const Text(
'Log in using your Hacker News account '
'to check out stories and comments you have '
'posted in the past, and get in-app '
'notification when there is new reply to '
'your comments or stories.',
style: TextStyle(fontSize: 16),
),
],
child: BlocBuilder<NotificationCubit,
NotificationState>(
builder: (context, state) {
if (state.unreadCommentsIds.isEmpty) {
return Icon(
Icons.person,
size: 16,
color: currentIndex == 5
? Colors.orange
: Colors.grey,
);
} else {
return Badge(
borderRadius: BorderRadius.circular(100),
badgeContent: Container(
height: 3,
width: 3,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white),
),
child: Icon(
Icons.person,
size: 16,
color: currentIndex == 5
? Colors.orange
: Colors.grey,
),
);
}
},
),
),
),
],
),
),
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: tabController,
children: [
ItemsListView<Story>(
pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerTop,
items: state.storiesByType[StoryType.top]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.top));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.top));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
ItemsListView<Story>(
pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerNew,
items: state.storiesByType[StoryType.latest]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.latest));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.latest));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
ItemsListView<Story>(
pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerAsk,
items: state.storiesByType[StoryType.ask]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.ask));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.ask));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
ItemsListView<Story>(
pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerShow,
items: state.storiesByType[StoryType.show]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.show));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.show));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
ItemsListView<Story>(
pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerJobs,
items: state.storiesByType[StoryType.jobs]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.jobs));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.jobs));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
const ProfileScreen(),
],
),
],
),
);
},
),
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: tabController,
children: [
StoriesListView(
storyType: StoryType.top,
header: pinnedStories,
onStoryTapped: onStoryTapped,
refreshController: refreshControllerTop,
),
StoriesListView(
storyType: StoryType.latest,
header: pinnedStories,
onStoryTapped: onStoryTapped,
refreshController: refreshControllerNew,
),
StoriesListView(
storyType: StoryType.ask,
header: pinnedStories,
onStoryTapped: onStoryTapped,
refreshController: refreshControllerAsk,
),
StoriesListView(
storyType: StoryType.show,
header: pinnedStories,
onStoryTapped: onStoryTapped,
refreshController: refreshControllerShow,
),
StoriesListView(
storyType: StoryType.jobs,
header: pinnedStories,
onStoryTapped: onStoryTapped,
refreshController: refreshControllerJobs,
),
const ProfileScreen(),
],
),
),
);
},
);
@ -431,6 +309,8 @@ class _HomeScreenState extends State<HomeScreen>
void onStoryTapped(Story story) {
final showWebFirst = context.read<PreferenceCubit>().state.showWebFirst;
final useReader = context.read<PreferenceCubit>().state.useReader;
final offlineReading = context.read<StoriesBloc>().state.offlineReading;
final firstTimeReading = cacheService.isFirstTimeReading(story.id);
// If a story is a job story and it has a link to the job posting,
// it would be better to just navigate to the web page.
@ -441,8 +321,8 @@ class _HomeScreenState extends State<HomeScreen>
arguments: StoryScreenArgs(story: story));
}
if (isJobWithLink ||
(showWebFirst && cacheService.isFirstTimeReading(story.id))) {
if (!offlineReading &&
(isJobWithLink || (showWebFirst && firstTimeReading))) {
LinkUtil.launchUrl(story.url, useReader: useReader);
cacheService.store(story.id);
}

View File

@ -16,7 +16,6 @@ import 'package:hacki/screens/profile/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/utils/utils.dart';
import 'package:in_app_review/in_app_review.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
enum _PageType {
@ -100,7 +99,7 @@ class _ProfileScreenState extends State<ProfileScreen>
if ((!authState.isLoggedIn ||
historyState.submittedItems.isEmpty) &&
historyState.status != HistoryStatus.loading) {
return const _CenteredMessageView(
return const CenteredMessageView(
content: 'Your past comments and stories will '
'show up here.',
);
@ -149,7 +148,7 @@ class _ProfileScreenState extends State<ProfileScreen>
builder: (context, favState) {
if (favState.favStories.isEmpty &&
favState.status != FavStatus.loading) {
return const _CenteredMessageView(
return const CenteredMessageView(
content:
'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
@ -236,6 +235,7 @@ class _ProfileScreenState extends State<ProfileScreen>
}
},
),
const OfflineListTile(),
SwitchListTile(
title: const Text('Notification on New Reply'),
subtitle: const Text(
@ -304,7 +304,9 @@ class _ProfileScreenState extends State<ProfileScreen>
HapticFeedback.lightImpact();
if (!val) {
context.read<CacheCubit>().deleteAll();
context
.read<CacheCubit>()
.deleteAllReadStoryIds();
}
context
@ -322,13 +324,6 @@ class _ProfileScreenState extends State<ProfileScreen>
context
.read<PreferenceCubit>()
.toggleEyeCandyMode();
final inAppReview = InAppReview.instance;
inAppReview.isAvailable().then((available) {
if (available) {
inAppReview.requestReview();
}
});
},
activeColor: Colors.orange,
),
@ -341,13 +336,6 @@ class _ProfileScreenState extends State<ProfileScreen>
context
.read<PreferenceCubit>()
.toggleTrueDarkMode();
final inAppReview = InAppReview.instance;
inAppReview.isAvailable().then((available) {
if (available) {
inAppReview.requestReview();
}
});
},
activeColor: Colors.orange,
),
@ -372,7 +360,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showAboutDialog(
context: context,
applicationName: 'Hacki',
applicationVersion: 'v0.1.9',
applicationVersion: 'v0.2.0',
applicationIcon: Image.asset(
Constants.hackiIconPath,
height: 50,
@ -381,7 +369,8 @@ class _ProfileScreenState extends State<ProfileScreen>
children: [
ElevatedButton(
onPressed: () => LinkUtil.launchUrl(
'https://livinglist.github.io'),
Constants.portfolioLink,
),
child: Row(
children: const [
Icon(
@ -396,7 +385,8 @@ class _ProfileScreenState extends State<ProfileScreen>
),
ElevatedButton(
onPressed: () => LinkUtil.launchUrl(
'https://github.com/Livinglist/Hacki'),
Constants.githubLink,
),
child: Row(
children: const [
Icon(
@ -409,6 +399,24 @@ class _ProfileScreenState extends State<ProfileScreen>
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launchUrl(
Platform.isIOS
? Constants.appStoreLink
: Constants.googlePlayLink,
),
child: Row(
children: const [
Icon(
Icons.thumb_up,
),
SizedBox(
width: 12,
),
Text('Like the App?'),
],
),
),
],
);
},
@ -872,28 +880,3 @@ class _ProfileScreenState extends State<ProfileScreen>
@override
bool get wantKeepAlive => true;
}
class _CenteredMessageView extends StatelessWidget {
const _CenteredMessageView({
Key? key,
required this.content,
}) : super(key: key);
final String content;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
top: 120,
left: 40,
right: 40,
),
child: Text(
content,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
);
}
}

View File

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class CenteredMessageView extends StatelessWidget {
const CenteredMessageView({
Key? key,
required this.content,
}) : super(key: key);
final String content;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
top: 120,
left: 40,
right: 40,
),
child: Text(
content,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
);
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:wakelock/wakelock.dart';
class OfflineListTile extends StatelessWidget {
const OfflineListTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocConsumer<StoriesBloc, StoriesState>(
listenWhen: (previous, current) =>
previous.downloadStatus != current.downloadStatus,
listener: (context, state) {
if (state.downloadStatus == StoriesDownloadStatus.failure) {
Wakelock.disable();
}
},
buildWhen: (previous, current) =>
previous.downloadStatus != current.downloadStatus,
builder: (context, state) {
final downloading =
state.downloadStatus == StoriesDownloadStatus.downloading;
return ListTile(
title: Text(
downloading ? 'Downloading All Stories...' : 'Download All Stories',
),
subtitle: const Text(
'download all latest stories that have at least one comment '
"for offline reading. (web page won't be downloaded)",
),
trailing: downloading
? const SizedBox(
height: 24,
width: 24,
child: CustomCircularProgressIndicator(),
)
: const Icon(Icons.download),
isThreeLine: true,
onTap: () {
Wakelock.enable();
context.read<StoriesBloc>().add(StoriesDownload());
},
);
},
);
}
}

View File

@ -1,2 +1,4 @@
export 'centered_message_view.dart';
export 'custom_chip.dart';
export 'inbox_view.dart';
export 'offline_list_tile.dart';

View File

@ -58,9 +58,10 @@ class StoryScreen extends StatefulWidget {
create: (context) => PostCubit(),
),
BlocProvider<CommentsCubit>(
create: (_) => CommentsCubit<Story>()
..init(
args.story,
create: (_) => CommentsCubit<Story>(
offlineReading: context.read<StoriesBloc>().state.offlineReading,
item: args.story,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetComment: args.targetComments?.last,
),
@ -153,7 +154,7 @@ class _StoryScreenState extends State<StoryScreen> {
final editCubit = context.read<EditCubit>();
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
return BlocConsumer<PostCubit, PostState>(
return BlocListener<PostCubit, PostState>(
listener: (context, postState) {
if (postState.status == PostStatus.successful) {
final verb =
@ -178,437 +179,332 @@ class _StoryScreenState extends State<StoryScreen> {
context.read<PostCubit>().reset();
}
},
builder: (context, postState) {
return BlocConsumer<CommentsCubit, CommentsState>(
listener: (context, state) {
if (state.status == CommentsStatus.loaded) {
refreshController
..refreshCompleted()
..loadComplete();
}
},
builder: (context, state) {
return BlocConsumer<EditCubit, EditState>(
listenWhen: (previous, current) {
return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited;
},
listener: (context, editState) {
if (editState.replyingTo != null ||
editState.itemBeingEdited != null) {
if (editState.text == null) {
commentEditingController.clear();
} else {
final text = editState.text!;
commentEditingController
..text = text
..selection = TextSelection.fromPosition(
TextPosition(offset: text.length));
}
} else {
child: BlocConsumer<CommentsCubit, CommentsState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, state) {
if (state.status == CommentsStatus.loaded) {
refreshController
..refreshCompleted()
..loadComplete();
}
},
builder: (context, state) {
return BlocListener<EditCubit, EditState>(
listenWhen: (previous, current) {
return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited;
},
listener: (context, editState) {
if (editState.replyingTo != null ||
editState.itemBeingEdited != null) {
if (editState.text == null) {
commentEditingController.clear();
} else {
final text = editState.text!;
commentEditingController
..text = text
..selection = TextSelection.fromPosition(
TextPosition(offset: text.length));
}
},
builder: (context, editState) {
final replyingTo = editCubit.state.replyingTo;
final editing = editCubit.state.itemBeingEdited;
return Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: AppBar(
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
elevation: 0,
actions: [
ScrollUpIconButton(
scrollController: scrollController,
} else {
commentEditingController.clear();
}
},
child: Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: AppBar(
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
elevation: 0,
actions: [
ScrollUpIconButton(
scrollController: scrollController,
),
PinIconButton(story: widget.story),
FavIconButton(storyId: widget.story.id),
LinkIconButton(storyId: widget.story.id),
],
),
body: SmartRefresher(
scrollController: scrollController,
enablePullUp: !state.onlyShowTargetComment,
enablePullDown: !state.onlyShowTargetComment,
header: WaterDropMaterialHeader(
backgroundColor: Colors.orange,
offset: topPadding,
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
builder: (context, mode) {
Widget body;
if (mode == LoadStatus.idle) {
body = const Text('');
} else if (mode == LoadStatus.loading) {
body = const Text('');
} else if (mode == LoadStatus.failed) {
body = const Text(
'',
);
} else if (mode == LoadStatus.canLoading) {
body = const Text(
'',
);
} else {
body = const Text('');
}
return SizedBox(
height: 55,
child: Center(child: body),
);
},
),
controller: refreshController,
onRefresh: () {
HapticFeedback.lightImpact();
locator.get<CacheService>().resetComments();
context.read<CommentsCubit>().refresh();
},
onLoading: () {},
child: ListView(
primary: false,
children: [
SizedBox(
height: topPadding,
),
const Padding(
padding: EdgeInsets.only(bottom: 6),
child: OfflineBanner(),
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: [
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
if (widget.story !=
context
.read<EditCubit>()
.state
.replyingTo) {
commentEditingController.clear();
}
editCubit.onReplyTapped(widget.story);
focusNode.requestFocus();
},
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.message,
),
SlidableAction(
onPressed: (_) => onMorePressed(widget.story),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.more_horiz,
),
],
),
BlocBuilder<PinCubit, PinState>(
builder: (context, pinState) {
final pinned = pinState.pinnedStoriesIds
.contains(widget.story.id);
return Transform.rotate(
angle: pi / 4,
child: Transform.translate(
offset: const Offset(2, 0),
child: IconButton(
icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode:
OverflowMode.extendBackground,
targetColor:
Theme.of(context).primaryColor,
tapTarget: Icon(
pinned
? Icons.push_pin
: Icons.push_pin_outlined,
color: Colors.white,
),
featureId: Constants.featurePinToTop,
title: const Text('Pin a Story'),
description: const Text(
'Pin this story to the top of your '
'home screen so that you can come'
' back later.',
style: TextStyle(fontSize: 16),
),
child: Icon(
pinned
? Icons.push_pin
: Icons.push_pin_outlined,
color: pinned
? Colors.orange
: Theme.of(context).iconTheme.color,
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(
left: 6,
right: 6,
),
child: Row(
children: [
Text(
widget.story.by,
style: const TextStyle(
color: Colors.orange,
),
),
onPressed: () {
HapticFeedback.lightImpact();
if (pinned) {
context
.read<PinCubit>()
.unpinStory(widget.story);
} else {
context
.read<PinCubit>()
.pinStory(widget.story);
}
},
const Spacer(),
Text(
widget.story.postedDate,
style: const TextStyle(
color: Colors.grey,
),
),
],
),
),
InkWell(
onTap: () => LinkUtil.launchUrl(
widget.story.url,
useReader: context
.read<PreferenceCubit>()
.state
.useReader,
),
child: Padding(
padding: const EdgeInsets.only(
left: 6,
right: 6,
bottom: 12,
top: 12,
),
child: Text(
widget.story.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
);
},
),
BlocBuilder<FavCubit, FavState>(
builder: (context, favState) {
final isFav =
favState.favIds.contains(widget.story.id);
return IconButton(
icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: Icon(
isFav
? Icons.favorite
: Icons.favorite_border,
color: Colors.white,
),
if (widget.story.text.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
featureId: Constants.featureAddStoryToFavList,
title: const Text('Fav a Story'),
description: const Text(
'Add it to your favorites.',
style: TextStyle(fontSize: 16),
),
child: Icon(
isFav
? Icons.favorite
: Icons.favorite_border,
color: isFav
? Colors.orange
: Theme.of(context).iconTheme.color,
child: SelectableHtml(
data: widget.story.text,
onLinkTap: (link, _, __, ___) =>
LinkUtil.launchUrl(link ?? ''),
),
),
onPressed: () {
HapticFeedback.lightImpact();
if (isFav) {
context
.read<FavCubit>()
.removeFav(widget.story.id);
} else {
context
.read<FavCubit>()
.addFav(widget.story.id);
}
},
);
},
],
),
IconButton(
icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.stream,
color: Colors.white,
),
featureId: Constants.featureOpenStoryInWebView,
title: const Text('Open in Browser'),
description: const Text(
'Want more than just reading and replying? '
'You can tap here to open this story in a '
'browser.',
style: TextStyle(fontSize: 16),
),
child: const Icon(
Icons.stream,
),
),
onPressed: () => LinkUtil.launchUrl(
'https://news.ycombinator.com/item?id=${widget.story.id}'),
),
if (widget.story.text.isNotEmpty)
const SizedBox(
height: 8,
),
const Divider(
height: 0,
),
if (state.onlyShowTargetComment) ...[
TextButton(
onPressed: () => context
.read<CommentsCubit>()
.loadAll(widget.story),
child: const Text('View all comments'),
),
const Divider(
height: 0,
),
],
),
body: SmartRefresher(
scrollController: scrollController,
enablePullUp: !state.onlyShowTargetComment,
enablePullDown: !state.onlyShowTargetComment,
header: WaterDropMaterialHeader(
backgroundColor: Colors.orange,
offset: topPadding,
if (state.comments.isEmpty &&
state.status == CommentsStatus.loaded) ...[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Colors.grey),
),
),
],
...state.comments.map(
(e) => FadeIn(
child: CommentTile(
comment: e,
onlyShowTargetComment:
state.onlyShowTargetComment,
targetComments: widget.parentComments.sublist(
0, max(widget.parentComments.length - 1, 0)),
myUsername: authState.isLoggedIn
? authState.username
: null,
onReplyTapped: (cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
if (cmt !=
context
.read<EditCubit>()
.state
.replyingTo) {
commentEditingController.clear();
}
editCubit.onReplyTapped(cmt);
focusNode.requestFocus();
},
onEditTapped: (cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
editCubit.onEditTapped(cmt);
focusNode.requestFocus();
},
onMoreTapped: onMorePressed,
onStoryLinkTapped: (link) {
final regex = RegExp(r'\d+$');
final match = regex.stringMatch(link) ?? '';
final id = int.tryParse(match);
if (id != null) {
throttle.run(() {
locator
.get<StoriesRepository>()
.fetchParentStory(id: id)
.then((story) {
if (mounted) {
if (story != null) {
HackiApp.navigatorKey.currentState!
.pushNamed(
StoryScreen.routeName,
arguments:
StoryScreenArgs(story: story),
);
} else {}
}
});
});
} else {
LinkUtil.launchUrl(link);
}
},
),
),
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
builder: (context, mode) {
Widget body;
if (mode == LoadStatus.idle) {
body = const Text('');
} else if (mode == LoadStatus.loading) {
body = const Text('');
} else if (mode == LoadStatus.failed) {
body = const Text(
'',
);
} else if (mode == LoadStatus.canLoading) {
body = const Text(
'',
);
} else {
body = const Text('');
}
return SizedBox(
height: 55,
child: Center(child: body),
const SizedBox(
height: 240,
),
],
),
),
bottomSheet: Offstage(
offstage: !editCubit.state.showReplyBox,
child: BlocBuilder<PostCubit, PostState>(
builder: (context, postState) {
return BlocBuilder<EditCubit, EditState>(
buildWhen: (previous, current) =>
previous.itemBeingEdited !=
current.itemBeingEdited ||
previous.replyingTo != current.replyingTo,
builder: (context, editState) {
return ReplyBox(
focusNode: focusNode,
textEditingController: commentEditingController,
editing: editState.itemBeingEdited,
replyingTo: editState.replyingTo,
isLoading: postState.status == PostStatus.loading,
onSendTapped: onSendTapped,
onCloseTapped: () {
editCubit.onReplyBoxClosed();
commentEditingController.clear();
focusNode.unfocus();
},
onChanged: editCubit.onTextChanged,
);
},
),
controller: refreshController,
onRefresh: () {
HapticFeedback.lightImpact();
locator.get<CacheService>().resetComments();
context.read<CommentsCubit>().refresh();
},
onLoading: () {},
child: ListView(
primary: false,
children: [
SizedBox(
height: topPadding,
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: [
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
setState(() {
if (widget.story != replyingTo) {
commentEditingController.clear();
}
editCubit.onReplyTapped(widget.story);
focusNode.requestFocus();
});
},
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.message,
),
SlidableAction(
onPressed: (_) =>
onMorePressed(widget.story),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.more_horiz,
),
],
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(
left: 6,
right: 6,
),
child: Row(
children: [
Text(
widget.story.by,
style: const TextStyle(
color: Colors.orange,
),
),
const Spacer(),
Text(
widget.story.postedDate,
style: const TextStyle(
color: Colors.grey,
),
),
],
),
),
InkWell(
onTap: () => LinkUtil.launchUrl(
widget.story.url,
useReader: context
.read<PreferenceCubit>()
.state
.useReader,
),
child: Padding(
padding: const EdgeInsets.only(
left: 6,
right: 6,
bottom: 12,
top: 12,
),
child: Text(
widget.story.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
),
if (widget.story.text.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
child: SelectableHtml(
data: widget.story.text,
onLinkTap: (link, _, __, ___) =>
LinkUtil.launchUrl(link ?? ''),
),
),
],
),
),
if (widget.story.text.isNotEmpty)
const SizedBox(
height: 8,
),
const Divider(
height: 0,
),
if (state.onlyShowTargetComment) ...[
TextButton(
onPressed: () => context
.read<CommentsCubit>()
.loadAll(widget.story),
child: const Text('View all comments'),
),
const Divider(
height: 0,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.loaded) ...[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Colors.white30),
),
),
],
...state.comments.map(
(e) => FadeIn(
child: CommentTile(
comment: e,
onlyShowTargetComment:
state.onlyShowTargetComment,
targetComments: widget.parentComments.sublist(
0,
max(widget.parentComments.length - 1, 0)),
myUsername: authState.isLoggedIn
? authState.username
: null,
onReplyTapped: (cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
if (cmt != replyingTo) {
commentEditingController.clear();
}
editCubit.onReplyTapped(cmt);
focusNode.requestFocus();
},
onEditTapped: (cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
editCubit.onEditTapped(cmt);
focusNode.requestFocus();
},
onMoreTapped: onMorePressed,
onStoryLinkTapped: (link) {
final regex = RegExp(r'\d+$');
final match = regex.stringMatch(link) ?? '';
final id = int.tryParse(match);
if (id != null) {
throttle.run(() {
locator
.get<StoriesRepository>()
.fetchParentStory(id: id)
.then((story) {
if (mounted) {
if (story != null) {
HackiApp
.navigatorKey.currentState!
.pushNamed(
StoryScreen.routeName,
arguments: StoryScreenArgs(
story: story),
);
} else {}
}
});
});
} else {
LinkUtil.launchUrl(link);
}
},
),
),
),
const SizedBox(
height: 240,
),
],
),
),
bottomSheet: Offstage(
offstage: !editCubit.state.showReplyBox,
child: ReplyBox(
focusNode: focusNode,
textEditingController: commentEditingController,
editing: editing,
replyingTo: replyingTo,
isLoading: postState.status == PostStatus.loading,
onSendTapped: onSendTapped,
onCloseTapped: () {
editCubit.onReplyBoxClosed();
commentEditingController.clear();
focusNode.unfocus();
},
onChanged: editCubit.onTextChanged,
),
),
);
},
);
},
);
},
);
},
),
),
),
);
},
),
);
},
);

View File

@ -0,0 +1,53 @@
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';
class FavIconButton extends StatelessWidget {
const FavIconButton({
Key? key,
required this.storyId,
}) : super(key: key);
final int storyId;
@override
Widget build(BuildContext context) {
return BlocBuilder<FavCubit, FavState>(
builder: (context, favState) {
final isFav = favState.favIds.contains(storyId);
return IconButton(
icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: Colors.white,
),
featureId: Constants.featureAddStoryToFavList,
title: const Text('Fav a Story'),
description: const Text(
'Add it to your favorites.',
style: TextStyle(fontSize: 16),
),
child: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Colors.orange : Theme.of(context).iconTheme.color,
),
),
onPressed: () {
HapticFeedback.lightImpact();
if (isFav) {
context.read<FavCubit>().removeFav(storyId);
} else {
context.read<FavCubit>().addFav(storyId);
}
},
);
},
);
}
}

View File

@ -0,0 +1,41 @@
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/utils/utils.dart';
class LinkIconButton extends StatelessWidget {
const LinkIconButton({
Key? key,
required this.storyId,
}) : super(key: key);
final int storyId;
@override
Widget build(BuildContext context) {
return IconButton(
icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.stream,
color: Colors.white,
),
featureId: Constants.featureOpenStoryInWebView,
title: const Text('Open in Browser'),
description: const Text(
'Want more than just reading and replying? '
'You can tap here to open this story in a '
'browser.',
style: TextStyle(fontSize: 16),
),
child: const Icon(
Icons.stream,
),
),
onPressed: () =>
LinkUtil.launchUrl('https://news.ycombinator.com/item?id=$storyId'),
);
}
}

View File

@ -0,0 +1,66 @@
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';
class PinIconButton extends StatelessWidget {
const PinIconButton({
Key? key,
required this.story,
}) : super(key: key);
final Story story;
@override
Widget build(BuildContext context) {
return BlocBuilder<PinCubit, PinState>(
builder: (context, pinState) {
final pinned = pinState.pinnedStoriesIds.contains(story.id);
return Transform.rotate(
angle: pi / 4,
child: Transform.translate(
offset: const Offset(2, 0),
child: IconButton(
icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Colors.white,
),
featureId: Constants.featurePinToTop,
title: const Text('Pin a Story'),
description: const Text(
'Pin this story to the top of your '
'home screen so that you can come'
' back later.',
style: TextStyle(fontSize: 16),
),
child: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: pinned
? Colors.orange
: Theme.of(context).iconTheme.color,
),
),
onPressed: () {
HapticFeedback.lightImpact();
if (pinned) {
context.read<PinCubit>().unpinStory(story);
} else {
context.read<PinCubit>().pinStory(story);
}
},
),
),
);
},
);
}
}

View File

@ -1,2 +1,5 @@
export 'fav_icon_button.dart';
export 'link_icon_button.dart';
export 'pin_icon_button.dart';
export 'reply_box.dart';
export 'scroll_up_icon_button.dart';

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
@ -41,6 +42,7 @@ class _SubmitScreenState extends State<SubmitScreen> {
listener: (context, state) {
if (state.status == SubmitStatus.submitted) {
Navigator.pop(context);
HapticFeedback.lightImpact();
showSnackBar(
content: 'Post submitted successfully.',
);

View File

@ -41,11 +41,13 @@ class CommentTile extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider<CommentsCubit>(
lazy: false,
create: (_) => CommentsCubit<Comment>()
..init(comment,
onlyShowTargetComment: onlyShowTargetComment,
targetComment:
targetComments.isNotEmpty ? targetComments.last : null),
create: (_) => CommentsCubit<Comment>(
offlineReading: context.read<StoriesBloc>().state.offlineReading,
item: comment,
)..init(
onlyShowTargetComment: onlyShowTargetComment,
targetComment: targetComments.isNotEmpty ? targetComments.last : null,
),
child: BlocBuilder<CommentsCubit, CommentsState>(
builder: (context, state) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
@ -207,31 +209,28 @@ class CommentTile extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(left: 12),
child: Column(
children: state.comments
.map(
(e) => FadeIn(
child: CommentTile(
comment: e,
onlyShowTargetComment:
onlyShowTargetComment &&
targetComments.length > 1,
targetComments: targetComments
.isNotEmpty
? targetComments.sublist(
0,
max(targetComments.length - 1,
0))
: [],
myUsername: myUsername,
onReplyTapped: onReplyTapped,
onMoreTapped: onMoreTapped,
onEditTapped: onEditTapped,
level: level + 1,
onStoryLinkTapped: onStoryLinkTapped,
),
children: [
...state.comments.map(
(e) => FadeIn(
child: CommentTile(
comment: e,
onlyShowTargetComment:
onlyShowTargetComment &&
targetComments.length > 1,
targetComments: targetComments.isNotEmpty
? targetComments.sublist(0,
max(targetComments.length - 1, 0))
: [],
myUsername: myUsername,
onReplyTapped: onReplyTapped,
onMoreTapped: onMoreTapped,
onEditTapped: onEditTapped,
level: level + 1,
onStoryLinkTapped: onStoryLinkTapped,
),
)
.toList(),
),
),
],
),
),
],

View File

@ -21,6 +21,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
this.pinnable = false,
this.markReadStories = false,
this.useConsistentFontSize = false,
this.showOfflineBanner = false,
this.onRefresh,
this.onLoadMore,
this.onPinned,
@ -31,6 +32,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final bool showWebPreview;
final bool enablePullDown;
final bool markReadStories;
final bool showOfflineBanner;
/// Whether story tiles can be pinned to the top.
final bool pinnable;
@ -50,6 +52,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
Widget build(BuildContext context) {
final child = ListView(
children: [
if (showOfflineBanner)
const OfflineBanner(
showExitButton: true,
),
if (header != null) header!,
...items.map((e) {
final wasRead =

View File

@ -12,16 +12,34 @@ import 'package:http/io_client.dart';
abstract class InfoBase {
late DateTime _timeout;
Map<String, dynamic> toJson();
}
/// Web Information
class WebInfo extends InfoBase {
WebInfo({this.title, this.icon, this.description, this.image});
WebInfo.fromJson(Map<String, dynamic> json)
: title = json['title'] as String?,
icon = json['icon'] as String?,
description = json['description'] as String?,
image = json['image'] as String?;
final String? title;
final String? icon;
final String? description;
final String? image;
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'title': title,
'icon': icon,
'description': description,
'image': image,
};
}
}
/// Image Information
@ -29,6 +47,13 @@ class WebImageInfo extends InfoBase {
WebImageInfo({this.image});
final String? image;
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'image': image,
};
}
}
/// Video Information

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
class OfflineBanner extends StatelessWidget {
const OfflineBanner({
Key? key,
this.showExitButton = false,
}) : super(key: key);
final bool showExitButton;
@override
Widget build(BuildContext context) {
return BlocBuilder<StoriesBloc, StoriesState>(
buildWhen: (previous, current) =>
previous.offlineReading != current.offlineReading,
builder: (context, state) {
if (state.offlineReading) {
return MaterialBanner(
content: Text(
'You are currently in offline mode. '
'${showExitButton ? 'Exit to fetch latest stories.' : ''}',
textAlign: showExitButton ? TextAlign.left : TextAlign.center,
),
backgroundColor: Colors.orangeAccent.withOpacity(0.3),
actions: [
if (showExitButton)
TextButton(
onPressed: () {
showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Exit offline mode?'),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(false),
child: const Text('Cancel')),
TextButton(
onPressed: () =>
Navigator.of(context).pop(true),
child: const Text(
'Yes',
style: TextStyle(
color: Colors.red,
),
),
),
],
);
}).then((value) {
if (value ?? false) {
context.read<StoriesBloc>().add(StoriesExitOffline());
context.read<AuthBloc>().add(AuthInitialize());
context.read<PinCubit>().init();
}
});
},
child: const Text('Exit'),
)
else
Container(),
],
);
}
return const SizedBox();
},
);
}
}

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class StoriesListView extends StatelessWidget {
const StoriesListView({
Key? key,
required this.storyType,
required this.header,
required this.onStoryTapped,
required this.refreshController,
}) : super(key: key);
final StoryType storyType;
final Widget header;
final ValueChanged<Story> onStoryTapped;
final RefreshController refreshController;
@override
Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (previous, current) =>
previous.showComplexStoryTile != current.showComplexStoryTile,
builder: (context, preferenceState) {
return BlocConsumer<StoriesBloc, StoriesState>(
listenWhen: (previous, current) =>
previous.statusByType[storyType] !=
current.statusByType[storyType],
listener: (context, state) {
if (state.statusByType[storyType] == StoriesStatus.loaded) {
refreshController
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
},
buildWhen: (previous, current) =>
(current.currentPageByType[storyType] == 0 &&
previous.currentPageByType[storyType] == 0) ||
(previous.storiesByType[storyType]!.length !=
current.storiesByType[storyType]!.length),
builder: (context, state) {
return ItemsListView<Story>(
pinnable: true,
showOfflineBanner: true,
markReadStories:
context.read<PreferenceCubit>().state.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshController,
items: state.storiesByType[storyType]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: storyType));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: storyType));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: state.offlineReading ? null : header,
);
},
);
},
);
}
}

View File

@ -3,6 +3,8 @@ export 'custom_circular_progress_indicator.dart';
export 'items_list_view.dart';
export 'link_preview/link_preview.dart';
export 'link_preview/link_preview.dart';
export 'offline_banner.dart';
export 'spring_curve.dart';
export 'stories_list_view.dart';
export 'story_tile.dart';
export 'tap_down_wrapper.dart';

View File

@ -1,6 +1,6 @@
name: hacki
description: A Hacker News reader.
version: 0.1.9+27
version: 0.2.0+31
publish_to: none
environment:
@ -8,9 +8,8 @@ environment:
dependencies:
adaptive_theme: ^2.3.0
algolia: ^1.0.1
badges: ^2.0.2
bloc: ^7.0.0
bloc: ^8.0.3
cached_network_image: ^3.2.0
clipboard: ^0.1.3
collection:
@ -20,8 +19,7 @@ dependencies:
feature_discovery: ^0.14.0
flutter:
sdk: flutter
flutter_app_badger: ^1.3.0
flutter_bloc: ^7.1.0
flutter_bloc: ^8.0.1
flutter_fadein: ^2.0.0
flutter_feather_icons: 2.0.0+1
flutter_html: ^2.2.1
@ -36,7 +34,6 @@ dependencies:
html: ^0.15.0
html_unescape: ^2.0.0
http: ^0.13.3
in_app_review: ^2.0.3
intl: ^0.17.0
path: ^1.8.0
path_provider: ^2.0.8
@ -47,12 +44,13 @@ dependencies:
tuple: ^2.0.0
universal_platform: ^1.0.0+1
url_launcher: ^6.0.10
wakelock: ^0.6.1+2
dev_dependencies:
bloc_test: ^8.1.0
bloc_test: ^9.0.3
flutter_test:
sdk: flutter
mocktail: ^0.1.0
mocktail: ^0.3.0
very_good_analysis: ^2.3.0
flutter: