Compare commits

..

17 Commits

Author SHA1 Message Date
8348a87a75 Merge pull request #7 from Livinglist/v0.1.9
fixed notification screen.
2022-03-02 20:21:48 -08:00
58837b6c00 fixed notification screen. 2022-03-02 20:21:25 -08:00
8365869ee8 Merge pull request #6 from Livinglist/v0.1.9
v0.1.9
2022-03-02 16:41:45 -08:00
5c70185236 fixed ui. 2022-03-02 16:37:58 -08:00
e4a385deb7 fixed imports 2022-03-02 16:13:58 -08:00
dfde6a74eb fixed inconsistent font size. 2022-03-02 16:10:26 -08:00
a2223dc531 added the feature where tapping on comment in notification or history screen will lead user directly to the comment. 2022-03-02 16:03:33 -08:00
f75e6a5e3b updated README.md 2022-02-28 21:27:18 -08:00
a87d521d32 updated README.md 2022-02-28 17:23:28 -08:00
94d76d4c20 removed firebase package.
removed firebase package.
2022-02-28 15:50:46 -08:00
1176e3bb80 removed firebase package. 2022-02-28 15:46:23 -08:00
52b63efe1a Merge pull request #4 from Livinglist/v0.1.8
v0.1.8
2022-02-28 11:08:59 -08:00
9652c08a4f added delete cache func. 2022-02-28 00:06:10 -08:00
ddb437cd60 fixed msg. 2022-02-27 19:52:42 -08:00
1719036d18 added edit comment feature. 2022-02-27 19:31:35 -08:00
6451495297 fixed UI. 2022-02-27 19:05:52 -08:00
d0b6f19a80 added mark read stories feature. 2022-02-27 19:03:46 -08:00
35 changed files with 986 additions and 392 deletions

View File

@ -1,6 +1,6 @@
# Hacki # Hacki for Hacker News
A simple Hacker News reader made with Flutter. A simple noiseless Hacker News reader made with Flutter that is just enough.
![iOS](https://img.shields.io/badge/iOS-13%20-blue) ![iOS](https://img.shields.io/badge/iOS-13%20-blue)
[![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763) [![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763)

View File

@ -0,0 +1 @@
- Updates to UI.

View File

@ -0,0 +1 @@
- Tapping on comments in notification and history screen will lead you directly to the comment.

View File

@ -365,7 +365,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1.7; MARKETING_VERSION = 0.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -500,7 +500,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1.7; MARKETING_VERSION = 0.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -529,7 +529,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1.7; MARKETING_VERSION = 0.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -14,5 +14,6 @@ Future<void> setUpLocator() async {
..registerSingleton<AuthRepository>(AuthRepository()) ..registerSingleton<AuthRepository>(AuthRepository())
..registerSingleton<PostRepository>(PostRepository()) ..registerSingleton<PostRepository>(PostRepository())
..registerSingleton<SembastRepository>(SembastRepository()) ..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<CacheRepository>(CacheRepository())
..registerSingleton<CacheService>(CacheService()); ..registerSingleton<CacheService>(CacheService());
} }

32
lib/cubits/cache/cache_cubit.dart vendored Normal file
View File

@ -0,0 +1,32 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/repositories/repositories.dart';
part 'cache_state.dart';
class CacheCubit extends Cubit<CacheState> {
CacheCubit({CacheRepository? cacheRepository})
: _cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
super(CacheState.init()) {
init();
}
final CacheRepository _cacheRepository;
void init() {
_cacheRepository.getAllReadStoriesIds().then((allReadStories) {
emit(state.copyWith(ids: allReadStories));
});
}
void markStoryAsRead(int id) {
emit(state.copyWithStoryMarkedAsRead(id: id));
_cacheRepository.cacheReadStory(id: id);
}
void deleteAll() {
emit(CacheState.init());
_cacheRepository.deleteAll();
}
}

30
lib/cubits/cache/cache_state.dart vendored Normal file
View File

@ -0,0 +1,30 @@
part of 'cache_cubit.dart';
class CacheState extends Equatable {
const CacheState({required this.storiesReadStatus});
CacheState.init() : storiesReadStatus = {};
final Map<int, bool> storiesReadStatus;
CacheState copyWith({required List<int> ids}) {
return CacheState(
storiesReadStatus: {
...storiesReadStatus,
...Map<int, bool>.fromEntries(
ids.map((e) => MapEntry<int, bool>(e, true)))
},
);
}
CacheState copyWithStoryMarkedAsRead({required int id}) {
return CacheState(storiesReadStatus: {...storiesReadStatus, id: true});
}
CacheState copyWithStoryMarkedAsUnread({required int id}) {
return CacheState(storiesReadStatus: {...storiesReadStatus, id: false});
}
@override
List<Object?> get props => [storiesReadStatus];
}

View File

@ -8,21 +8,31 @@ import 'package:hacki/services/cache_service.dart';
part 'comments_state.dart'; part 'comments_state.dart';
class CommentsCubit<T extends Item> extends Cubit<CommentsState> { class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
CommentsCubit( CommentsCubit({
{required T item,
CacheService? cacheService, CacheService? cacheService,
StoriesRepository? storiesRepository}) StoriesRepository? storiesRepository,
: _cacheService = cacheService ?? locator.get<CacheService>(), }) : _cacheService = cacheService ?? locator.get<CacheService>(),
_storiesRepository = _storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(), storiesRepository ?? locator.get<StoriesRepository>(),
super(CommentsState.init()) { super(CommentsState.init());
init(item);
}
final CacheService _cacheService; final CacheService _cacheService;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
Future<void> init(T item) async { Future<void> init(
T item, {
bool onlyShowTargetComment = false,
Comment? targetComment,
}) async {
if (onlyShowTargetComment) {
emit(state.copyWith(
item: item,
comments: targetComment != null ? [targetComment] : [],
onlyShowTargetComment: true,
));
return;
}
if (item is Story) { if (item is Story) {
final story = item; final story = item;
final updatedStory = await _storiesRepository.fetchStoryById(story.id); final updatedStory = await _storiesRepository.fetchStoryById(story.id);
@ -97,6 +107,14 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
emit(state.copyWith(collapsed: !state.collapsed)); emit(state.copyWith(collapsed: !state.collapsed));
} }
void loadAll(T item) {
emit(state.copyWith(
onlyShowTargetComment: false,
comments: [],
));
init(item);
}
void _onCommentFetched(Comment? comment) { void _onCommentFetched(Comment? comment) {
if (comment != null) { if (comment != null) {
_cacheService.cacheComment(comment); _cacheService.cacheComment(comment);

View File

@ -13,30 +13,36 @@ class CommentsState extends Equatable {
required this.comments, required this.comments,
required this.status, required this.status,
required this.collapsed, required this.collapsed,
required this.onlyShowTargetComment,
}); });
CommentsState.init() CommentsState.init()
: item = null, : item = null,
comments = [], comments = [],
status = CommentsStatus.init, status = CommentsStatus.init,
collapsed = false; collapsed = false,
onlyShowTargetComment = false;
final Item? item; final Item? item;
final List<Comment> comments; final List<Comment> comments;
final CommentsStatus status; final CommentsStatus status;
final bool collapsed; final bool collapsed;
final bool onlyShowTargetComment;
CommentsState copyWith({ CommentsState copyWith({
Item? item, Item? item,
List<Comment>? comments, List<Comment>? comments,
CommentsStatus? status, CommentsStatus? status,
bool? collapsed, bool? collapsed,
bool? onlyShowTargetComment,
}) { }) {
return CommentsState( return CommentsState(
item: item ?? this.item, item: item ?? this.item,
comments: comments ?? this.comments, comments: comments ?? this.comments,
status: status ?? this.status, status: status ?? this.status,
collapsed: collapsed ?? this.collapsed, collapsed: collapsed ?? this.collapsed,
onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment,
); );
} }
@ -46,5 +52,6 @@ class CommentsState extends Equatable {
comments, comments,
status, status,
collapsed, collapsed,
onlyShowTargetComment,
]; ];
} }

View File

@ -1,4 +1,5 @@
export 'blocklist/blocklist_cubit.dart'; export 'blocklist/blocklist_cubit.dart';
export 'cache/cache_cubit.dart';
export 'comments/comments_cubit.dart'; export 'comments/comments_cubit.dart';
export 'edit/edit_cubit.dart'; export 'edit/edit_cubit.dart';
export 'fav/fav_cubit.dart'; export 'fav/fav_cubit.dart';

View File

@ -16,13 +16,20 @@ class EditCubit extends Cubit<EditState> {
final CacheService _cacheService; final CacheService _cacheService;
final Debouncer _debouncer; final Debouncer _debouncer;
void onItemTapped(Item item) { void onReplyTapped(Item item) {
emit(EditState( emit(EditState(
replyingTo: item, replyingTo: item,
text: _cacheService.getDraft(replyingTo: item.id), text: _cacheService.getDraft(replyingTo: item.id),
)); ));
} }
void onEditTapped(Item itemToBeEdited) {
emit(EditState(
itemBeingEdited: itemToBeEdited,
text: itemToBeEdited.text,
));
}
void onReplyBoxClosed() { void onReplyBoxClosed() {
emit(const EditState.init()); emit(const EditState.init());
} }

View File

@ -4,20 +4,24 @@ class EditState extends Equatable {
const EditState({ const EditState({
this.text, this.text,
this.replyingTo, this.replyingTo,
this.itemBeingEdited,
}); });
const EditState.init() const EditState.init()
: text = null, : text = null,
replyingTo = null; replyingTo = null,
itemBeingEdited = null;
final String? text; final String? text;
final Item? replyingTo; final Item? replyingTo;
final Item? itemBeingEdited;
bool get showReplyBox => replyingTo != null; bool get showReplyBox => replyingTo != null || itemBeingEdited != null;
EditState copyWith({String? text}) { EditState copyWith({String? text}) {
return EditState( return EditState(
replyingTo: replyingTo, replyingTo: replyingTo,
itemBeingEdited: itemBeingEdited,
text: text ?? this.text, text: text ?? this.text,
); );
} }
@ -26,5 +30,6 @@ class EditState extends Equatable {
List<Object?> get props => [ List<Object?> get props => [
text, text,
replyingTo, replyingTo,
itemBeingEdited,
]; ];
} }

View File

@ -3,7 +3,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';

View File

@ -27,6 +27,17 @@ class PostCubit extends Cubit<PostState> {
} }
} }
Future<void> edit({required String text, required int id}) async {
emit(state.copyWith(status: PostStatus.loading));
final successful = await _postRepository.edit(id: id, text: text);
if (successful) {
emit(state.copyWith(status: PostStatus.successful));
} else {
emit(state.copyWith(status: PostStatus.failure));
}
}
void reset() { void reset() {
emit(state.copyWith(status: PostStatus.init)); emit(state.copyWith(status: PostStatus.init));
} }

View File

@ -28,6 +28,8 @@ class PreferenceCubit extends Cubit<PreferenceState> {
.then((value) => emit(state.copyWith(useTrueDark: value))); .then((value) => emit(state.copyWith(useTrueDark: value)));
_storageRepository.readerMode _storageRepository.readerMode
.then((value) => emit(state.copyWith(useReader: value))); .then((value) => emit(state.copyWith(useReader: value)));
_storageRepository.markReadStories
.then((value) => emit(state.copyWith(markReadStories: value)));
} }
void toggleNotificationMode() { void toggleNotificationMode() {
@ -59,4 +61,9 @@ class PreferenceCubit extends Cubit<PreferenceState> {
emit(state.copyWith(useReader: !state.useReader)); emit(state.copyWith(useReader: !state.useReader));
_storageRepository.toggleReaderMode(); _storageRepository.toggleReaderMode();
} }
void toggleMarkReadStoriesMode() {
emit(state.copyWith(markReadStories: !state.markReadStories));
_storageRepository.toggleMarkReadStoriesMode();
}
} }

View File

@ -8,6 +8,7 @@ class PreferenceState extends Equatable {
required this.showEyeCandy, required this.showEyeCandy,
required this.useTrueDark, required this.useTrueDark,
required this.useReader, required this.useReader,
required this.markReadStories,
}); });
const PreferenceState.init() const PreferenceState.init()
@ -16,7 +17,8 @@ class PreferenceState extends Equatable {
showWebFirst = false, showWebFirst = false,
showEyeCandy = false, showEyeCandy = false,
useTrueDark = false, useTrueDark = false,
useReader = false; useReader = false,
markReadStories = false;
final bool showNotification; final bool showNotification;
final bool showComplexStoryTile; final bool showComplexStoryTile;
@ -24,6 +26,7 @@ class PreferenceState extends Equatable {
final bool showEyeCandy; final bool showEyeCandy;
final bool useTrueDark; final bool useTrueDark;
final bool useReader; final bool useReader;
final bool markReadStories;
PreferenceState copyWith({ PreferenceState copyWith({
bool? showNotification, bool? showNotification,
@ -32,6 +35,7 @@ class PreferenceState extends Equatable {
bool? showEyeCandy, bool? showEyeCandy,
bool? useTrueDark, bool? useTrueDark,
bool? useReader, bool? useReader,
bool? markReadStories,
}) { }) {
return PreferenceState( return PreferenceState(
showNotification: showNotification ?? this.showNotification, showNotification: showNotification ?? this.showNotification,
@ -40,6 +44,7 @@ class PreferenceState extends Equatable {
showEyeCandy: showEyeCandy ?? this.showEyeCandy, showEyeCandy: showEyeCandy ?? this.showEyeCandy,
useTrueDark: useTrueDark ?? this.useTrueDark, useTrueDark: useTrueDark ?? this.useTrueDark,
useReader: useReader ?? this.useReader, useReader: useReader ?? this.useReader,
markReadStories: markReadStories ?? this.markReadStories,
); );
} }
@ -51,5 +56,6 @@ class PreferenceState extends Equatable {
showEyeCandy, showEyeCandy,
useTrueDark, useTrueDark,
useReader, useReader,
markReadStories,
]; ];
} }

View File

@ -7,10 +7,16 @@ import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hive/hive.dart';
import 'package:path_provider/path_provider.dart';
Future main() async { Future main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final tempDir = await getTemporaryDirectory();
final tempPath = tempDir.path;
Hive.init(tempPath);
await setUpLocator(); await setUpLocator();
final savedThemeMode = await AdaptiveTheme.getThemeMode(); final savedThemeMode = await AdaptiveTheme.getThemeMode();
@ -77,6 +83,10 @@ class HackiApp extends StatelessWidget {
lazy: false, lazy: false,
create: (context) => PinCubit(), create: (context) => PinCubit(),
), ),
BlocProvider<CacheCubit>(
lazy: false,
create: (context) => CacheCubit(),
),
], ],
child: AdaptiveTheme( child: AdaptiveTheme(
light: ThemeData( light: ThemeData(

View File

@ -6,6 +6,7 @@ class Comment extends Item {
required int id, required int id,
required int time, required int time,
required int parent, required int parent,
required int score,
required String by, required String by,
required String text, required String text,
required List<int> kids, required List<int> kids,
@ -18,7 +19,7 @@ class Comment extends Item {
kids: kids, kids: kids,
parent: parent, parent: parent,
deleted: deleted, deleted: deleted,
score: 0, score: score,
descendants: 0, descendants: 0,
dead: false, dead: false,
parts: [], parts: [],
@ -37,7 +38,7 @@ class Comment extends Item {
kids: (json['kids'] as List?)?.cast<int>() ?? [], kids: (json['kids'] as List?)?.cast<int>() ?? [],
parent: json['parent'] as int? ?? 0, parent: json['parent'] as int? ?? 0,
deleted: json['deleted'] as bool? ?? false, deleted: json['deleted'] as bool? ?? false,
score: 0, score: json['score'] as int? ?? 0,
descendants: 0, descendants: 0,
dead: json['dead'] as bool? ?? false, dead: json['dead'] as bool? ?? false,
parts: [], parts: [],
@ -59,6 +60,7 @@ class Comment extends Item {
'parent': parent, 'parent': parent,
'deleted': deleted, 'deleted': deleted,
'dead': dead, 'dead': dead,
'score': score,
}; };
@override @override

View File

@ -146,6 +146,27 @@ class SubmitPostData with PostDataMixin {
} }
} }
class EditPostData with PostDataMixin {
EditPostData({
required this.hmac,
required this.id,
this.text,
});
final String hmac;
final int id;
final String? text;
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'hmac': hmac,
'id': id,
'text': text,
};
}
}
class FormPostData with PostDataMixin { class FormPostData with PostDataMixin {
FormPostData({ FormPostData({
required this.acct, required this.acct,

View File

@ -0,0 +1,31 @@
import 'package:hive/hive.dart';
class CacheRepository {
CacheRepository({Future<Box<bool>>? box})
: _box = box ?? Hive.openBox<bool>(_boxName);
static const _boxName = 'cacheBox';
final Future<Box<bool>> _box;
Future<bool> wasRead({required int id}) async {
final box = await _box;
final val = box.get(id.toString());
return val != null;
}
Future<void> cacheReadStory({required int id}) async {
final box = await _box;
return box.put(id.toString(), true);
}
Future<List<int>> getAllReadStoriesIds() async {
final box = await _box;
final allReads = box.keys.cast<String>().map(int.parse).toList();
return allReads;
}
Future<int> deleteAll() async {
final box = await _box;
return box.clear();
}
}

View File

@ -84,6 +84,45 @@ class PostRepository {
); );
} }
Future<bool> edit({
required int id,
String? text,
}) async {
final username = await _storageRepository.username;
final password = await _storageRepository.password;
if (username == null || password == null) {
return false;
}
final formResponse = await _getFormResponse(
username: username,
password: password,
id: id,
path: 'edit',
);
final formValues = HtmlUtil.getHiddenFormValues(formResponse.data);
if (formValues == null || formValues.isEmpty) {
return false;
}
final cookie = formResponse.headers.value(HttpHeaders.setCookieHeader);
final uri = Uri.https(authority, 'xedit');
final PostDataMixin data = EditPostData(
hmac: formValues['hmac']!,
id: id,
text: text,
);
return _performDefaultPost(
uri,
data,
cookie: cookie,
);
}
Future<Response<List<int>>> _getFormResponse({ Future<Response<List<int>>> _getFormResponse({
required String username, required String username,
required String password, required String password,

View File

@ -1,4 +1,5 @@
export 'auth_repository.dart'; export 'auth_repository.dart';
export 'cache_repository.dart';
export 'post_repository.dart'; export 'post_repository.dart';
export 'search_repository.dart'; export 'search_repository.dart';
export 'sembast_repository.dart'; export 'sembast_repository.dart';

View File

@ -25,8 +25,8 @@ class StorageRepository {
/// The key of a boolean value deciding whether or not user should be /// The key of a boolean value deciding whether or not user should be
/// navigated to web view first. Defaults to false. /// navigated to web view first. Defaults to false.
static const String _navigationModeKey = 'navigationMode'; static const String _navigationModeKey = 'navigationMode';
static const String _eyeCandyModeKey = 'eyeCandyMode'; static const String _eyeCandyModeKey = 'eyeCandyMode';
static const String _markReadStoriesModeKey = 'markReadStoriesMode';
static const bool _notificationModeDefaultValue = true; static const bool _notificationModeDefaultValue = true;
static const bool _displayModeDefaultValue = true; static const bool _displayModeDefaultValue = true;
@ -34,6 +34,7 @@ class StorageRepository {
static const bool _eyeCandyModeDefaultValue = false; static const bool _eyeCandyModeDefaultValue = false;
static const bool _trueDarkModeDefaultValue = false; static const bool _trueDarkModeDefaultValue = false;
static const bool _readerModeDefaultValue = true; static const bool _readerModeDefaultValue = true;
static const bool _markReadStoriesModeDefaultValue = true;
final Future<SharedPreferences> _prefs; final Future<SharedPreferences> _prefs;
final FlutterSecureStorage _secureStorage; final FlutterSecureStorage _secureStorage;
@ -68,6 +69,10 @@ class StorageRepository {
Future<bool> get readerMode async => _prefs.then( Future<bool> get readerMode async => _prefs.then(
(prefs) => prefs.getBool(_readerModeKey) ?? _readerModeDefaultValue); (prefs) => prefs.getBool(_readerModeKey) ?? _readerModeDefaultValue);
Future<bool> get markReadStories async => _prefs.then((prefs) =>
prefs.getBool(_markReadStoriesModeKey) ??
_markReadStoriesModeDefaultValue);
Future<List<int>> get unreadCommentsIds async => _prefs.then((prefs) => Future<List<int>> get unreadCommentsIds async => _prefs.then((prefs) =>
prefs.getStringList(_unreadCommentsIdsKey)?.map(int.parse).toList() ?? prefs.getStringList(_unreadCommentsIdsKey)?.map(int.parse).toList() ??
[]); []);
@ -141,6 +146,13 @@ class StorageRepository {
await prefs.setBool(_readerModeKey, !currentMode); await prefs.setBool(_readerModeKey, !currentMode);
} }
Future<void> toggleMarkReadStoriesMode() async {
final prefs = await _prefs;
final currentMode = prefs.getBool(_markReadStoriesModeKey) ??
_markReadStoriesModeDefaultValue;
await prefs.setBool(_markReadStoriesModeKey, !currentMode);
}
Future<void> addFav({required String username, required int id}) async { Future<void> addFav({required String username, required int id}) async {
final prefs = await _prefs; final prefs = await _prefs;
final key = _getFavKey(username); final key = _getFavKey(username);

View File

@ -1,7 +1,8 @@
import 'package:firebase/firebase_io.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:html_unescape/html_unescape.dart'; import 'package:html_unescape/html_unescape.dart';
import 'package:tuple/tuple.dart';
class StoriesRepository { class StoriesRepository {
StoriesRepository({ StoriesRepository({
@ -128,7 +129,7 @@ class StoriesRepository {
Future<Item?> fetchItemBy({required int id}) async { Future<Item?> fetchItemBy({required int id}) async {
final item = await _firebaseClient final item = await _firebaseClient
.get('${_baseUrl}item/$id.json') .get('${_baseUrl}item/$id.json')
.then((dynamic val) { .then((dynamic val) async {
if (val == null) { if (val == null) {
return null; return null;
} }
@ -138,6 +139,9 @@ class StoriesRepository {
final story = Story.fromJson(json); final story = Story.fromJson(json);
return story; return story;
} else if (json['type'] == 'comment') { } else if (json['type'] == 'comment') {
final text = json['text'] as String? ?? '';
final parsedText = await compute<String, String>(_parseHtml, text);
json['text'] = parsedText;
final comment = Comment.fromJson(json); final comment = Comment.fromJson(json);
return comment; return comment;
} }
@ -172,6 +176,22 @@ class StoriesRepository {
return item as Story; return item as Story;
} }
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments(
{required int id}) async {
Item? item;
final 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);
return Tuple2<Story, List<Comment>>(item as Story, parentComments);
}
static String _parseHtml(String text) { static String _parseHtml(String text) {
return HtmlUnescape() return HtmlUnescape()
.convert(text) .convert(text)

View File

@ -1,3 +1,5 @@
// ignore_for_file: lines_longer_than_80_chars
import 'package:badges/badges.dart'; import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -77,6 +79,8 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>( return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (previous, current) =>
previous.showComplexStoryTile != current.showComplexStoryTile,
builder: (context, preferenceState) { builder: (context, preferenceState) {
final pinnedStories = BlocBuilder<PinCubit, PinState>( final pinnedStories = BlocBuilder<PinCubit, PinState>(
builder: (context, state) { builder: (context, state) {
@ -154,6 +158,8 @@ class _HomeScreenState extends State<HomeScreen>
} }
}, },
builder: (context, state) { builder: (context, state) {
return BlocBuilder<CacheCubit, CacheState>(
builder: (context, cacheState) {
return DefaultTabController( return DefaultTabController(
length: 6, length: 6,
child: Scaffold( child: Scaffold(
@ -242,9 +248,7 @@ class _HomeScreenState extends State<HomeScreen>
'to check out stories and comments you have ' 'to check out stories and comments you have '
'posted in the past, and get in-app ' 'posted in the past, and get in-app '
'notification when there is new reply to ' 'notification when there is new reply to '
'your comments or stories.\n\nAlso, you can ' 'your comments or stories.',
'long press here to submit a new link to '
'Hacker News.',
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: 16),
), ),
child: BlocBuilder<NotificationCubit, child: BlocBuilder<NotificationCubit,
@ -260,7 +264,8 @@ class _HomeScreenState extends State<HomeScreen>
); );
} else { } else {
return Badge( return Badge(
borderRadius: BorderRadius.circular(100), borderRadius:
BorderRadius.circular(100),
badgeContent: Container( badgeContent: Container(
height: 3, height: 3,
width: 3, width: 3,
@ -292,6 +297,10 @@ class _HomeScreenState extends State<HomeScreen>
children: [ children: [
ItemsListView<Story>( ItemsListView<Story>(
pinnable: true, pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile, showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerTop, refreshController: refreshControllerTop,
items: state.storiesByType[StoryType.top]!, items: state.storiesByType[StoryType.top]!,
@ -312,6 +321,10 @@ class _HomeScreenState extends State<HomeScreen>
), ),
ItemsListView<Story>( ItemsListView<Story>(
pinnable: true, pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile, showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerNew, refreshController: refreshControllerNew,
items: state.storiesByType[StoryType.latest]!, items: state.storiesByType[StoryType.latest]!,
@ -332,6 +345,10 @@ class _HomeScreenState extends State<HomeScreen>
), ),
ItemsListView<Story>( ItemsListView<Story>(
pinnable: true, pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile, showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerAsk, refreshController: refreshControllerAsk,
items: state.storiesByType[StoryType.ask]!, items: state.storiesByType[StoryType.ask]!,
@ -352,6 +369,10 @@ class _HomeScreenState extends State<HomeScreen>
), ),
ItemsListView<Story>( ItemsListView<Story>(
pinnable: true, pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile, showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerShow, refreshController: refreshControllerShow,
items: state.storiesByType[StoryType.show]!, items: state.storiesByType[StoryType.show]!,
@ -372,6 +393,10 @@ class _HomeScreenState extends State<HomeScreen>
), ),
ItemsListView<Story>( ItemsListView<Story>(
pinnable: true, pinnable: true,
markReadStories: context
.read<PreferenceCubit>()
.state
.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile, showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerJobs, refreshController: refreshControllerJobs,
items: state.storiesByType[StoryType.jobs]!, items: state.storiesByType[StoryType.jobs]!,
@ -399,6 +424,8 @@ class _HomeScreenState extends State<HomeScreen>
); );
}, },
); );
},
);
} }
void onStoryTapped(Story story) { void onStoryTapped(Story story) {
@ -419,5 +446,7 @@ class _HomeScreenState extends State<HomeScreen>
LinkUtil.launchUrl(story.url, useReader: useReader); LinkUtil.launchUrl(story.url, useReader: useReader);
cacheService.store(story.id); cacheService.store(story.id);
} }
context.read<CacheCubit>().markStoryAsRead(story.id);
} }
} }

View File

@ -40,6 +40,7 @@ class _ProfileScreenState extends State<ProfileScreen>
final refreshControllerFav = RefreshController(); final refreshControllerFav = RefreshController();
final refreshControllerNotification = RefreshController(); final refreshControllerNotification = RefreshController();
final scrollController = ScrollController(); final scrollController = ScrollController();
final throttle = Throttle(delay: const Duration(seconds: 2));
_PageType pageType = _PageType.notification; _PageType pageType = _PageType.notification;
@ -52,6 +53,16 @@ class _ProfileScreenState extends State<ProfileScreen>
'to infinity and beyond!', 'to infinity and beyond!',
]; ];
@override
void dispose() {
super.dispose();
refreshControllerHistory.dispose();
refreshControllerFav.dispose();
refreshControllerNotification.dispose();
scrollController.dispose();
throttle.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
@ -61,6 +72,8 @@ class _ProfileScreenState extends State<ProfileScreen>
return BlocBuilder<AuthBloc, AuthState>( return BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) { builder: (context, authState) {
return BlocConsumer<NotificationCubit, NotificationState>( return BlocConsumer<NotificationCubit, NotificationState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, notificationState) { listener: (context, notificationState) {
if (notificationState.status == NotificationStatus.loaded) { if (notificationState.status == NotificationStatus.loaded) {
refreshControllerNotification refreshControllerNotification
@ -71,30 +84,10 @@ class _ProfileScreenState extends State<ProfileScreen>
builder: (context, notificationState) { builder: (context, notificationState) {
return Stack( return Stack(
children: [ children: [
if (!authState.isLoggedIn && pageType == _PageType.history)
Positioned.fill(
child: Column(
children: [
const SizedBox(
height: 120,
),
ElevatedButton(
onPressed: onLoginTapped,
style: ElevatedButton.styleFrom(
primary: Colors.deepOrange),
child: const Text(
'Log in',
style: TextStyle(color: Colors.white),
),
)
],
),
),
Positioned.fill( Positioned.fill(
top: 50, top: 50,
child: Offstage( child: Offstage(
offstage: !authState.isLoggedIn || offstage: pageType != _PageType.history,
pageType != _PageType.history,
child: BlocConsumer<HistoryCubit, HistoryState>( child: BlocConsumer<HistoryCubit, HistoryState>(
listener: (context, historyState) { listener: (context, historyState) {
if (historyState.status == HistoryStatus.loaded) { if (historyState.status == HistoryStatus.loaded) {
@ -104,8 +97,18 @@ class _ProfileScreenState extends State<ProfileScreen>
} }
}, },
builder: (context, historyState) { builder: (context, historyState) {
if ((!authState.isLoggedIn ||
historyState.submittedItems.isEmpty) &&
historyState.status != HistoryStatus.loading) {
return const _CenteredMessageView(
content: 'Your past comments and stories will '
'show up here.',
);
}
return ItemsListView<Item>( return ItemsListView<Item>(
showWebPreview: false, showWebPreview: false,
useConsistentFontSize: true,
refreshController: refreshControllerHistory, refreshController: refreshControllerHistory,
items: historyState.submittedItems items: historyState.submittedItems
.where((e) => !e.dead && !e.deleted) .where((e) => !e.dead && !e.deleted)
@ -123,17 +126,7 @@ class _ProfileScreenState extends State<ProfileScreen>
StoryScreen.routeName, StoryScreen.routeName,
arguments: StoryScreenArgs(story: item)); arguments: StoryScreenArgs(story: item));
} else if (item is Comment) { } else if (item is Comment) {
locator onCommentTapped(item);
.get<StoriesRepository>()
.fetchParentStory(id: item.parent)
.then((story) {
if (story != null && mounted) {
HackiApp.navigatorKey.currentState!
.pushNamed(StoryScreen.routeName,
arguments: StoryScreenArgs(
story: story));
}
});
} }
}, },
); );
@ -154,6 +147,15 @@ class _ProfileScreenState extends State<ProfileScreen>
} }
}, },
builder: (context, favState) { builder: (context, favState) {
if (favState.favStories.isEmpty &&
favState.status != FavStatus.loading) {
return const _CenteredMessageView(
content:
'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
);
}
return ItemsListView<Story>( return ItemsListView<Story>(
showWebPreview: showWebPreview:
preferenceState.showComplexStoryTile, preferenceState.showComplexStoryTile,
@ -192,22 +194,9 @@ class _ProfileScreenState extends State<ProfileScreen>
unreadCommentsIds: unreadCommentsIds:
notificationState.unreadCommentsIds, notificationState.unreadCommentsIds,
comments: notificationState.comments, comments: notificationState.comments,
onCommentTapped: (comment) { onCommentTapped: (cmt) {
locator onCommentTapped(cmt, then: () {
.get<StoriesRepository>() context.read<NotificationCubit>().markAsRead(cmt);
.fetchParentStory(id: comment.parent)
.then((story) {
if (story != null && mounted) {
context
.read<NotificationCubit>()
.markAsRead(comment);
HackiApp.navigatorKey.currentState!.pushNamed(
StoryScreen.routeName,
arguments: StoryScreenArgs(
story: story,
),
);
}
}); });
}, },
onMarkAllAsReadTapped: () { onMarkAllAsReadTapped: () {
@ -306,6 +295,24 @@ class _ProfileScreenState extends State<ProfileScreen>
}, },
activeColor: Colors.orange, activeColor: Colors.orange,
), ),
SwitchListTile(
title: const Text('Mark Read Stories'),
subtitle: const Text(
'grey out stories you have read.'),
value: preferenceState.markReadStories,
onChanged: (val) {
HapticFeedback.lightImpact();
if (!val) {
context.read<CacheCubit>().deleteAll();
}
context
.read<PreferenceCubit>()
.toggleMarkReadStoriesMode();
},
activeColor: Colors.orange,
),
SwitchListTile( SwitchListTile(
title: const Text('Eye Candy'), title: const Text('Eye Candy'),
subtitle: const Text('some sort of magic.'), subtitle: const Text('some sort of magic.'),
@ -365,7 +372,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: 'Hacki', applicationName: 'Hacki',
applicationVersion: 'v0.1.7', applicationVersion: 'v0.1.9',
applicationIcon: Image.asset( applicationIcon: Image.asset(
Constants.hackiIconPath, Constants.hackiIconPath,
height: 50, height: 50,
@ -453,7 +460,7 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
CustomChip( CustomChip(
label: 'Inbox : ' label: 'Inbox : '
//ignore: lines_longer_than_80_chars // ignore: lines_longer_than_80_chars
'${notificationState.unreadCommentsIds.length}', '${notificationState.unreadCommentsIds.length}',
selected: pageType == _PageType.notification, selected: pageType == _PageType.notification,
onSelected: (val) { onSelected: (val) {
@ -579,6 +586,30 @@ class _ProfileScreenState extends State<ProfileScreen>
}); });
} }
void onCommentTapped(Comment comment, {VoidCallback? then}) {
throttle.run(() {
locator
.get<StoriesRepository>()
.fetchParentStoryWithComments(id: comment.parent)
.then((tuple) {
if (tuple != null && mounted) {
HackiApp.navigatorKey.currentState!
.pushNamed(
StoryScreen.routeName,
arguments: StoryScreenArgs(
story: tuple.item1,
targetComments: tuple.item2.isEmpty
? [comment]
: [comment, ...tuple.item2],
onlyShowTargetComment: true,
),
)
.then((_) => then?.call());
}
});
});
}
void onLoginTapped() { void onLoginTapped() {
final usernameController = TextEditingController(); final usernameController = TextEditingController();
final passwordController = TextEditingController(); final passwordController = TextEditingController();
@ -841,3 +872,28 @@ class _ProfileScreenState extends State<ProfileScreen>
@override @override
bool get wantKeepAlive => true; 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

@ -8,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
@ -30,13 +31,21 @@ enum _MenuAction {
} }
class StoryScreenArgs { class StoryScreenArgs {
StoryScreenArgs({required this.story}); StoryScreenArgs({
required this.story,
this.onlyShowTargetComment = false,
this.targetComments,
});
final Story story; final Story story;
final bool onlyShowTargetComment;
final List<Comment>? targetComments;
} }
class StoryScreen extends StatefulWidget { class StoryScreen extends StatefulWidget {
const StoryScreen({Key? key, required this.story}) : super(key: key); const StoryScreen(
{Key? key, required this.story, required this.parentComments})
: super(key: key);
static const String routeName = '/story'; static const String routeName = '/story';
@ -49,8 +58,11 @@ class StoryScreen extends StatefulWidget {
create: (context) => PostCubit(), create: (context) => PostCubit(),
), ),
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (_) => CommentsCubit<Story>( create: (_) => CommentsCubit<Story>()
item: args.story, ..init(
args.story,
onlyShowTargetComment: args.onlyShowTargetComment,
targetComment: args.targetComments?.last,
), ),
), ),
BlocProvider<EditCubit>( BlocProvider<EditCubit>(
@ -59,12 +71,14 @@ class StoryScreen extends StatefulWidget {
], ],
child: StoryScreen( child: StoryScreen(
story: args.story, story: args.story,
parentComments: args.targetComments ?? [],
), ),
), ),
); );
} }
final Story story; final Story story;
final List<Comment> parentComments;
@override @override
_StoryScreenState createState() => _StoryScreenState(); _StoryScreenState createState() => _StoryScreenState();
@ -142,15 +156,13 @@ class _StoryScreenState extends State<StoryScreen> {
return BlocConsumer<PostCubit, PostState>( return BlocConsumer<PostCubit, PostState>(
listener: (context, postState) { listener: (context, postState) {
if (postState.status == PostStatus.successful) { if (postState.status == PostStatus.successful) {
editCubit.onReplySubmittedSuccessfully(); final verb =
editCubit.state.replyingTo == null ? 'updated' : 'submitted';
final msg = 'Comment $verb! ${(happyFaces..shuffle()).first}';
focusNode.unfocus(); focusNode.unfocus();
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(SnackBar( showSnackBar(content: msg);
content: Text( editCubit.onReplySubmittedSuccessfully();
'Comment submitted! ${(happyFaces..shuffle()).first}',
),
backgroundColor: Colors.orange,
));
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) { } else if (postState.status == PostStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(
@ -178,10 +190,12 @@ class _StoryScreenState extends State<StoryScreen> {
builder: (context, state) { builder: (context, state) {
return BlocConsumer<EditCubit, EditState>( return BlocConsumer<EditCubit, EditState>(
listenWhen: (previous, current) { listenWhen: (previous, current) {
return previous.replyingTo != current.replyingTo; return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited;
}, },
listener: (context, editState) { listener: (context, editState) {
if (editState.replyingTo != null) { if (editState.replyingTo != null ||
editState.itemBeingEdited != null) {
if (editState.text == null) { if (editState.text == null) {
commentEditingController.clear(); commentEditingController.clear();
} else { } else {
@ -197,6 +211,7 @@ class _StoryScreenState extends State<StoryScreen> {
}, },
builder: (context, editState) { builder: (context, editState) {
final replyingTo = editCubit.state.replyingTo; final replyingTo = editCubit.state.replyingTo;
final editing = editCubit.state.itemBeingEdited;
return Scaffold( return Scaffold(
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
@ -336,7 +351,8 @@ class _StoryScreenState extends State<StoryScreen> {
), ),
body: SmartRefresher( body: SmartRefresher(
scrollController: scrollController, scrollController: scrollController,
enablePullUp: true, enablePullUp: !state.onlyShowTargetComment,
enablePullDown: !state.onlyShowTargetComment,
header: WaterDropMaterialHeader( header: WaterDropMaterialHeader(
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
offset: topPadding, offset: topPadding,
@ -379,17 +395,34 @@ class _StoryScreenState extends State<StoryScreen> {
SizedBox( SizedBox(
height: topPadding, height: topPadding,
), ),
InkWell( Slidable(
onTap: () { startActionPane: ActionPane(
motion: const BehindMotion(),
children: [
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
setState(() { setState(() {
if (widget.story != replyingTo) { if (widget.story != replyingTo) {
commentEditingController.clear(); commentEditingController.clear();
} }
editCubit.onItemTapped(widget.story); editCubit.onReplyTapped(widget.story);
focusNode.requestFocus(); focusNode.requestFocus();
}); });
}, },
onLongPress: () => onMorePressed(widget.story), 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( child: Column(
children: [ children: [
Padding( Padding(
@ -439,17 +472,37 @@ class _StoryScreenState extends State<StoryScreen> {
), ),
), ),
if (widget.story.text.isNotEmpty) if (widget.story.text.isNotEmpty)
Html( Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
child: SelectableHtml(
data: widget.story.text, data: widget.story.text,
onLinkTap: (link, _, __, ___) => onLinkTap: (link, _, __, ___) =>
LinkUtil.launchUrl(link ?? ''), LinkUtil.launchUrl(link ?? ''),
), ),
),
], ],
), ),
), ),
if (widget.story.text.isNotEmpty)
const SizedBox(
height: 8,
),
const Divider( const Divider(
height: 0, 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 && if (state.comments.isEmpty &&
state.status == CommentsStatus.loaded) ...[ state.status == CommentsStatus.loaded) ...[
const SizedBox( const SizedBox(
@ -466,6 +519,11 @@ class _StoryScreenState extends State<StoryScreen> {
(e) => FadeIn( (e) => FadeIn(
child: CommentTile( child: CommentTile(
comment: e, comment: e,
onlyShowTargetComment:
state.onlyShowTargetComment,
targetComments: widget.parentComments.sublist(
0,
max(widget.parentComments.length - 1, 0)),
myUsername: authState.isLoggedIn myUsername: authState.isLoggedIn
? authState.username ? authState.username
: null, : null,
@ -479,7 +537,16 @@ class _StoryScreenState extends State<StoryScreen> {
commentEditingController.clear(); commentEditingController.clear();
} }
editCubit.onItemTapped(cmt); editCubit.onReplyTapped(cmt);
focusNode.requestFocus();
},
onEditTapped: (cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
editCubit.onEditTapped(cmt);
focusNode.requestFocus(); focusNode.requestFocus();
}, },
onMoreTapped: onMorePressed, onMoreTapped: onMorePressed,
@ -524,6 +591,7 @@ class _StoryScreenState extends State<StoryScreen> {
child: ReplyBox( child: ReplyBox(
focusNode: focusNode, focusNode: focusNode,
textEditingController: commentEditingController, textEditingController: commentEditingController,
editing: editing,
replyingTo: replyingTo, replyingTo: replyingTo,
isLoading: postState.status == PostStatus.loading, isLoading: postState.status == PostStatus.loading,
onSendTapped: onSendTapped, onSendTapped: onSendTapped,
@ -614,6 +682,9 @@ class _StoryScreenState extends State<StoryScreen> {
? const TextStyle(color: Colors.orange) ? const TextStyle(color: Colors.orange)
: null, : null,
), ),
subtitle: item is Story
? Text(item.score.toString())
: null,
onTap: context.read<VoteCubit>().upvote, onTap: context.read<VoteCubit>().upvote,
), ),
ListTile( ListTile(
@ -745,10 +816,7 @@ class _StoryScreenState extends State<StoryScreen> {
}).then((yesTapped) { }).then((yesTapped) {
if (yesTapped ?? false) { if (yesTapped ?? false) {
context.read<AuthBloc>().add(AuthFlag(item: item)); context.read<AuthBloc>().add(AuthFlag(item: item));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( showSnackBar(content: 'Comment flagged!');
content: Text('Comment flagged!'),
backgroundColor: Colors.orange,
));
} }
}); });
} }
@ -817,10 +885,7 @@ class _StoryScreenState extends State<StoryScreen> {
} else { } else {
context.read<BlocklistCubit>().addToBlocklist(item.by); context.read<BlocklistCubit>().addToBlocklist(item.by);
} }
ScaffoldMessenger.of(context).showSnackBar(SnackBar( showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
content: Text('User ${isBlocked ? 'unblocked' : 'blocked'}!'),
backgroundColor: Colors.orange,
));
} }
}); });
} }
@ -828,7 +893,9 @@ class _StoryScreenState extends State<StoryScreen> {
void onSendTapped() { void onSendTapped() {
final authBloc = context.read<AuthBloc>(); final authBloc = context.read<AuthBloc>();
final postCubit = context.read<PostCubit>(); final postCubit = context.read<PostCubit>();
final replyingTo = context.read<EditCubit>().state.replyingTo; final editState = context.read<EditCubit>().state;
final replyingTo = editState.replyingTo;
final itemEdited = editState.itemBeingEdited;
if (authBloc.state.isLoggedIn) { if (authBloc.state.isLoggedIn) {
final text = commentEditingController.text; final text = commentEditingController.text;
@ -836,7 +903,9 @@ class _StoryScreenState extends State<StoryScreen> {
return; return;
} }
if (replyingTo != null) { if (itemEdited != null) {
postCubit.edit(text: text, id: itemEdited.id);
} else if (replyingTo != null) {
postCubit.post(text: text, to: replyingTo.id); postCubit.post(text: text, to: replyingTo.id);
} }
} else { } else {
@ -857,12 +926,7 @@ class _StoryScreenState extends State<StoryScreen> {
listener: (context, state) { listener: (context, state) {
if (state.isLoggedIn) { if (state.isLoggedIn) {
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( showSnackBar(content: 'Logged in successfully! $happyFace');
SnackBar(
content: Text('Logged in successfully! $happyFace'),
backgroundColor: Colors.orange,
),
);
} }
}, },
builder: (context, state) { builder: (context, state) {

View File

@ -11,6 +11,7 @@ class ReplyBox extends StatefulWidget {
required this.focusNode, required this.focusNode,
required this.textEditingController, required this.textEditingController,
required this.replyingTo, required this.replyingTo,
required this.editing,
required this.onSendTapped, required this.onSendTapped,
required this.onCloseTapped, required this.onCloseTapped,
required this.onChanged, required this.onChanged,
@ -20,6 +21,7 @@ class ReplyBox extends StatefulWidget {
final FocusNode focusNode; final FocusNode focusNode;
final TextEditingController textEditingController; final TextEditingController textEditingController;
final Item? replyingTo; final Item? replyingTo;
final Item? editing;
final VoidCallback onSendTapped; final VoidCallback onSendTapped;
final VoidCallback onCloseTapped; final VoidCallback onCloseTapped;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
@ -66,14 +68,16 @@ class _ReplyBoxState extends State<ReplyBox> {
), ),
child: Text( child: Text(
widget.replyingTo == null widget.replyingTo == null
? '' ? 'Editing'
: 'Replying ' : 'Replying '
'${widget.replyingTo?.by}', '${widget.replyingTo?.by}',
style: const TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
), ),
), ),
const Spacer(), const Spacer(),
if (widget.replyingTo != null && !widget.isLoading) ...[ if (!widget.isLoading) ...[
...[
if (widget.replyingTo != null)
AnimatedOpacity( AnimatedOpacity(
opacity: expanded ? 1 : 0, opacity: expanded ? 1 : 0,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@ -102,6 +106,7 @@ class _ReplyBoxState extends State<ReplyBox> {
}); });
}, },
), ),
],
IconButton( IconButton(
key: const Key('close'), key: const Key('close'),
icon: const Icon( icon: const Icon(

View File

@ -1,8 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -14,23 +18,34 @@ class CommentTile extends StatelessWidget {
required this.comment, required this.comment,
required this.onReplyTapped, required this.onReplyTapped,
required this.onMoreTapped, required this.onMoreTapped,
required this.onEditTapped,
required this.onStoryLinkTapped, required this.onStoryLinkTapped,
this.loadKids = true, this.loadKids = true,
this.onlyShowTargetComment = false,
this.level = 0, this.level = 0,
this.targetComments = const [],
}) : super(key: key); }) : super(key: key);
final String? myUsername; final String? myUsername;
final Comment comment; final Comment comment;
final int level; final int level;
final bool loadKids; final bool loadKids;
final bool onlyShowTargetComment;
final Function(Comment) onReplyTapped; final Function(Comment) onReplyTapped;
final Function(Comment) onMoreTapped; final Function(Comment) onMoreTapped;
final Function(Comment) onEditTapped;
final Function(String) onStoryLinkTapped; final Function(String) onStoryLinkTapped;
final List<Comment> targetComments;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<CommentsCubit>( return BlocProvider<CommentsCubit>(
create: (_) => CommentsCubit<Comment>(item: comment), lazy: false,
create: (_) => CommentsCubit<Comment>()
..init(comment,
onlyShowTargetComment: onlyShowTargetComment,
targetComment:
targetComments.isNotEmpty ? targetComments.last : null),
child: BlocBuilder<CommentsCubit, CommentsState>( child: BlocBuilder<CommentsCubit, CommentsState>(
builder: (context, state) { builder: (context, state) {
return BlocBuilder<PreferenceCubit, PreferenceState>( return BlocBuilder<PreferenceCubit, PreferenceState>(
@ -68,14 +83,20 @@ class CommentTile extends StatelessWidget {
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
foregroundColor: Colors.white, foregroundColor: Colors.white,
icon: Icons.message, icon: Icons.message,
label: 'Reply', ),
if (context.read<AuthBloc>().state.user.id ==
comment.by)
SlidableAction(
onPressed: (_) => onEditTapped(comment),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.edit,
), ),
SlidableAction( SlidableAction(
onPressed: (_) => onMoreTapped(comment), onPressed: (_) => onMoreTapped(comment),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
foregroundColor: Colors.white, foregroundColor: Colors.white,
icon: Icons.more_horiz, icon: Icons.more_horiz,
label: 'More',
), ),
], ],
), ),
@ -84,8 +105,10 @@ class CommentTile extends StatelessWidget {
children: [ children: [
GestureDetector( GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => onTap: () {
context.read<CommentsCubit>().collapse(), HapticFeedback.lightImpact();
context.read<CommentsCubit>().collapse();
},
child: Padding( child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 6, right: 6, top: 6), left: 6, right: 6, top: 6),
@ -189,9 +212,20 @@ class CommentTile extends StatelessWidget {
(e) => FadeIn( (e) => FadeIn(
child: CommentTile( child: CommentTile(
comment: e, comment: e,
onlyShowTargetComment:
onlyShowTargetComment &&
targetComments.length > 1,
targetComments: targetComments
.isNotEmpty
? targetComments.sublist(
0,
max(targetComments.length - 1,
0))
: [],
myUsername: myUsername, myUsername: myUsername,
onReplyTapped: onReplyTapped, onReplyTapped: onReplyTapped,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onEditTapped: onEditTapped,
level: level + 1, level: level + 1,
onStoryLinkTapped: onStoryLinkTapped, onStoryLinkTapped: onStoryLinkTapped,
), ),

View File

@ -1,11 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/custom_circular_progress_indicator.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/screens/widgets/story_tile.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart';
@ -18,6 +19,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
required this.refreshController, required this.refreshController,
this.enablePullDown = true, this.enablePullDown = true,
this.pinnable = false, this.pinnable = false,
this.markReadStories = false,
this.useConsistentFontSize = false,
this.onRefresh, this.onRefresh,
this.onLoadMore, this.onLoadMore,
this.onPinned, this.onPinned,
@ -27,9 +30,14 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final bool showWebPreview; final bool showWebPreview;
final bool enablePullDown; final bool enablePullDown;
final bool markReadStories;
/// Whether story tiles can be pinned to the top. /// Whether story tiles can be pinned to the top.
final bool pinnable; final bool pinnable;
/// Whether to use same font size for comment and story tiles.
final bool useConsistentFontSize;
final List<T> items; final List<T> items;
final Widget? header; final Widget? header;
final RefreshController? refreshController; final RefreshController? refreshController;
@ -44,6 +52,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
children: [ children: [
if (header != null) header!, if (header != null) header!,
...items.map((e) { ...items.map((e) {
final wasRead =
context.read<CacheCubit>().state.storiesReadStatus[e.id] ?? false;
if (e is Story) { if (e is Story) {
return [ return [
FadeIn( FadeIn(
@ -72,6 +82,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
story: e, story: e,
onTap: () => onTap(e), onTap: () => onTap(e),
showWebPreview: showWebPreview, showWebPreview: showWebPreview,
wasRead: markReadStories && wasRead,
simpleTileFontSize: useConsistentFontSize ? 14 : 16,
), ),
), ),
), ),

View File

@ -137,9 +137,9 @@ class WebAnalyzer {
} }
static Future<List<dynamic>?> _isolate(dynamic message) async { static Future<List<dynamic>?> _isolate(dynamic message) async {
//ignore: avoid_dynamic_calls // ignore: avoid_dynamic_calls
final url = message[0] as String; final url = message[0] as String;
//ignore: avoid_dynamic_calls // ignore: avoid_dynamic_calls
final multimedia = message[1] as bool; final multimedia = message[1] as bool;
final info = await _getInfo(url, multimedia); final info = await _getInfo(url, multimedia);

View File

@ -10,14 +10,18 @@ import 'package:shimmer/shimmer.dart';
class StoryTile extends StatelessWidget { class StoryTile extends StatelessWidget {
const StoryTile({ const StoryTile({
Key? key, Key? key,
this.wasRead = false,
required this.showWebPreview, required this.showWebPreview,
required this.story, required this.story,
required this.onTap, required this.onTap,
this.simpleTileFontSize = 16,
}) : super(key: key); }) : super(key: key);
final bool showWebPreview; final bool showWebPreview;
final bool wasRead;
final Story story; final Story story;
final VoidCallback onTap; final VoidCallback onTap;
final double simpleTileFontSize;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -118,7 +122,9 @@ class StoryTile extends StatelessWidget {
bodyMaxLines: 4, bodyMaxLines: 4,
errorTitle: story.title, errorTitle: story.title,
titleStyle: TextStyle( titleStyle: TextStyle(
color: Theme.of(context).textTheme.subtitle1!.color, color: wasRead
? Colors.grey[500]
: Theme.of(context).textTheme.subtitle1!.color,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -145,7 +151,9 @@ class StoryTile extends StatelessWidget {
imagePath: Constants.hackerNewsLogoPath, imagePath: Constants.hackerNewsLogoPath,
bodyMaxLines: 4, bodyMaxLines: 4,
titleTextStyle: TextStyle( titleTextStyle: TextStyle(
color: Theme.of(context).textTheme.subtitle1!.color, color: wasRead
? Colors.grey[500]
: Theme.of(context).textTheme.subtitle1!.color,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -170,7 +178,10 @@ class StoryTile extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
story.title, story.title,
style: const TextStyle(fontSize: 15), style: TextStyle(
color: wasRead ? Colors.grey[500] : null,
fontSize: simpleTileFontSize,
),
), ),
), ),
], ],

View File

@ -0,0 +1,119 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart';
/// FirebaseClient wraps a REST client for a Firebase realtime database.
///
/// The client supports authentication and GET, PUT, POST, DELETE
/// and PATCH methods.
class FirebaseClient {
/// Creates a new FirebaseClient with [credential] and optional [client].
///
/// For credential you can either use Firebase app's secret or
/// an authentication token.
/// See: <https://firebase.google.com/docs/reference/rest/database/user-auth>.
FirebaseClient(this.credential, {Client? client})
: _client = client ?? Client();
/// Creates a new anonymous FirebaseClient with optional [client].
FirebaseClient.anonymous({Client? client})
: credential = null,
_client = client ?? Client();
/// Auth credential.
final String? credential;
final Client _client;
/// Reads data from database using a HTTP GET request.
/// The response from a successful request contains a data being retrieved.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-get>.
Future<dynamic> get(dynamic uri) => send('GET', uri);
/// Writes or replaces data in database using a HTTP PUT request.
/// The response from a successful request contains a data being written.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-put>.
Future<dynamic> put(dynamic uri, dynamic json) =>
send('PUT', uri, json: json);
/// Pushes data to database using a HTTP POST request.
/// The response from a successful request contains a key of the new data
/// being added.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-post>.
Future<dynamic> post(dynamic uri, dynamic json) =>
send('POST', uri, json: json);
/// Updates specific children at a location without overwriting existing data
/// using a HTTP PATCH request.
/// The response from a successful request contains a data being written.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-patch>.
Future<dynamic> patch(dynamic uri, dynamic json) =>
send('PATCH', uri, json: json);
/// Deletes data from database using a HTTP DELETE request.
/// The response from a successful request contains a JSON with `null`.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-delete>.
Future<void> delete(dynamic uri) => send('DELETE', uri);
/// Creates a request with a HTTP [method], [url] and optional data.
/// The [url] can be either a `String` or `Uri`.
Future<Object?> send(String method, dynamic url, {dynamic json}) async {
final uri = url is String ? Uri.parse(url) : url as Uri;
final request = Request(method, uri);
if (credential != null) {
request.headers['Authorization'] = 'Bearer $credential';
}
if (json != null) {
request.headers['Content-Type'] = 'application/json';
request.body = jsonEncode(json);
}
final streamedResponse = await _client.send(request);
final response = await Response.fromStream(streamedResponse);
Object? bodyJson;
try {
bodyJson = jsonDecode(response.body);
} on FormatException {
final contentType = response.headers['content-type'];
if (contentType != null && !contentType.contains('application/json')) {
throw Exception(
"Returned value was not JSON. Did the uri end with '.json'?");
}
rethrow;
}
if (response.statusCode != 200) {
if (bodyJson is Map) {
final dynamic error = bodyJson['error'];
if (error != null) {
throw FirebaseClientException(response.statusCode, error.toString());
}
}
throw FirebaseClientException(response.statusCode, bodyJson.toString());
}
return bodyJson;
}
/// Closes the client and cleans up any associated resources.
void close() => _client.close();
}
class FirebaseClientException implements Exception {
FirebaseClientException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => '$message ($statusCode)';
}

View File

@ -1 +1,2 @@
export 'cache_service.dart'; export 'cache_service.dart';
export 'firebase_client.dart';

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 0.1.7+24 version: 0.1.9+27
publish_to: none publish_to: none
environment: environment:
@ -18,8 +18,6 @@ dependencies:
equatable: 2.0.3 equatable: 2.0.3
fast_gbk: ^1.0.0 fast_gbk: ^1.0.0
feature_discovery: ^0.14.0 feature_discovery: ^0.14.0
firebase_analytics: ^8.3.4
firebase_core: ^1.6.0
flutter: flutter:
sdk: flutter sdk: flutter
flutter_app_badger: ^1.3.0 flutter_app_badger: ^1.3.0
@ -34,6 +32,7 @@ dependencies:
font_awesome_flutter: ^9.2.0 font_awesome_flutter: ^9.2.0
gbk_codec: ^0.4.0 gbk_codec: ^0.4.0
get_it: 7.2.0 get_it: 7.2.0
hive: ^2.0.6
html: ^0.15.0 html: ^0.15.0
html_unescape: ^2.0.0 html_unescape: ^2.0.0
http: ^0.13.3 http: ^0.13.3
@ -45,6 +44,7 @@ dependencies:
sembast: ^3.1.1+1 sembast: ^3.1.1+1
shared_preferences: ^2.0.11 shared_preferences: ^2.0.11
shimmer: ^2.0.0 shimmer: ^2.0.0
tuple: ^2.0.0
universal_platform: ^1.0.0+1 universal_platform: ^1.0.0+1
url_launcher: ^6.0.10 url_launcher: ^6.0.10