Compare commits

..

6 Commits

Author SHA1 Message Date
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
27 changed files with 686 additions and 338 deletions

View File

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

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 = 4;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -365,7 +365,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.7;
MARKETING_VERSION = 0.1.8;
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 = 4;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -500,7 +500,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.7;
MARKETING_VERSION = 0.1.8;
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 = 4;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -529,7 +529,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.7;
MARKETING_VERSION = 0.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -14,5 +14,6 @@ Future<void> setUpLocator() async {
..registerSingleton<AuthRepository>(AuthRepository())
..registerSingleton<PostRepository>(PostRepository())
..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<CacheRepository>(CacheRepository())
..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

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

View File

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

View File

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

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() {
emit(state.copyWith(status: PostStatus.init));
}

View File

@ -28,6 +28,8 @@ class PreferenceCubit extends Cubit<PreferenceState> {
.then((value) => emit(state.copyWith(useTrueDark: value)));
_storageRepository.readerMode
.then((value) => emit(state.copyWith(useReader: value)));
_storageRepository.markReadStories
.then((value) => emit(state.copyWith(markReadStories: value)));
}
void toggleNotificationMode() {
@ -59,4 +61,9 @@ class PreferenceCubit extends Cubit<PreferenceState> {
emit(state.copyWith(useReader: !state.useReader));
_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.useTrueDark,
required this.useReader,
required this.markReadStories,
});
const PreferenceState.init()
@ -16,7 +17,8 @@ class PreferenceState extends Equatable {
showWebFirst = false,
showEyeCandy = false,
useTrueDark = false,
useReader = false;
useReader = false,
markReadStories = false;
final bool showNotification;
final bool showComplexStoryTile;
@ -24,6 +26,7 @@ class PreferenceState extends Equatable {
final bool showEyeCandy;
final bool useTrueDark;
final bool useReader;
final bool markReadStories;
PreferenceState copyWith({
bool? showNotification,
@ -32,6 +35,7 @@ class PreferenceState extends Equatable {
bool? showEyeCandy,
bool? useTrueDark,
bool? useReader,
bool? markReadStories,
}) {
return PreferenceState(
showNotification: showNotification ?? this.showNotification,
@ -40,6 +44,7 @@ class PreferenceState extends Equatable {
showEyeCandy: showEyeCandy ?? this.showEyeCandy,
useTrueDark: useTrueDark ?? this.useTrueDark,
useReader: useReader ?? this.useReader,
markReadStories: markReadStories ?? this.markReadStories,
);
}
@ -51,5 +56,6 @@ class PreferenceState extends Equatable {
showEyeCandy,
useTrueDark,
useReader,
markReadStories,
];
}

View File

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

View File

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

View File

@ -1,4 +1,5 @@
export 'auth_repository.dart';
export 'cache_repository.dart';
export 'post_repository.dart';
export 'search_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
/// navigated to web view first. Defaults to false.
static const String _navigationModeKey = 'navigationMode';
static const String _eyeCandyModeKey = 'eyeCandyMode';
static const String _markReadStoriesModeKey = 'markReadStoriesMode';
static const bool _notificationModeDefaultValue = true;
static const bool _displayModeDefaultValue = true;
@ -34,6 +34,7 @@ class StorageRepository {
static const bool _eyeCandyModeDefaultValue = false;
static const bool _trueDarkModeDefaultValue = false;
static const bool _readerModeDefaultValue = true;
static const bool _markReadStoriesModeDefaultValue = true;
final Future<SharedPreferences> _prefs;
final FlutterSecureStorage _secureStorage;
@ -68,6 +69,10 @@ class StorageRepository {
Future<bool> get readerMode async => _prefs.then(
(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) =>
prefs.getStringList(_unreadCommentsIdsKey)?.map(int.parse).toList() ??
[]);
@ -141,6 +146,13 @@ class StorageRepository {
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 {
final prefs = await _prefs;
final key = _getFavKey(username);

View File

@ -1,3 +1,5 @@
// ignore_for_file: lines_longer_than_80_chars
import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
@ -77,6 +79,8 @@ class _HomeScreenState extends State<HomeScreen>
@override
Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (previous, current) =>
previous.showComplexStoryTile != current.showComplexStoryTile,
builder: (context, preferenceState) {
final pinnedStories = BlocBuilder<PinCubit, PinState>(
builder: (context, state) {
@ -154,246 +158,271 @@ class _HomeScreenState extends State<HomeScreen>
}
},
builder: (context, state) {
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,
),
),
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,
),
Tab(
child: Text(
'NEW',
style: TextStyle(
fontSize: 14,
color: currentIndex == 1
? 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(
'ASK',
style: TextStyle(
fontSize: 14,
color: currentIndex == 2
? Colors.orange
: Colors.grey,
Tab(
child: Text(
'NEW',
style: TextStyle(
fontSize: 14,
color: currentIndex == 1
? Colors.orange
: Colors.grey,
),
),
),
),
),
Tab(
child: Text(
'SHOW',
style: TextStyle(
fontSize: 13,
color: currentIndex == 3
? Colors.orange
: Colors.grey,
Tab(
child: Text(
'ASK',
style: TextStyle(
fontSize: 14,
color: currentIndex == 2
? Colors.orange
: Colors.grey,
),
),
),
),
),
Tab(
child: Text(
'JOBS',
style: TextStyle(
fontSize: 14,
color: currentIndex == 4
? Colors.orange
: Colors.grey,
Tab(
child: Text(
'SHOW',
style: TextStyle(
fontSize: 13,
color: currentIndex == 3
? 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: Text(
'JOBS',
style: TextStyle(
fontSize: 14,
color: currentIndex == 4
? 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.\n\nAlso, you can '
'long press here to submit a new link to '
'Hacker News.',
style: TextStyle(fontSize: 16),
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.\n\nAlso, you can '
'long press here to submit a new link to '
'Hacker News.',
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,
),
);
}
},
),
),
),
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: [
ItemsListView<Story>(
pinnable: true,
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,
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,
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,
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,
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(),
],
),
),
);
},
);
},
);
@ -419,5 +448,7 @@ class _HomeScreenState extends State<HomeScreen>
LinkUtil.launchUrl(story.url, useReader: useReader);
cacheService.store(story.id);
}
context.read<CacheCubit>().markStoryAsRead(story.id);
}
}

View File

@ -61,6 +61,8 @@ class _ProfileScreenState extends State<ProfileScreen>
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
return BlocConsumer<NotificationCubit, NotificationState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, notificationState) {
if (notificationState.status == NotificationStatus.loaded) {
refreshControllerNotification
@ -71,30 +73,10 @@ class _ProfileScreenState extends State<ProfileScreen>
builder: (context, notificationState) {
return Stack(
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(
top: 50,
child: Offstage(
offstage: !authState.isLoggedIn ||
pageType != _PageType.history,
offstage: pageType != _PageType.history,
child: BlocConsumer<HistoryCubit, HistoryState>(
listener: (context, historyState) {
if (historyState.status == HistoryStatus.loaded) {
@ -104,6 +86,14 @@ class _ProfileScreenState extends State<ProfileScreen>
}
},
builder: (context, historyState) {
if (!authState.isLoggedIn ||
historyState.submittedItems.isEmpty) {
return const _CenteredMessageView(
content: 'Your past comments and stories will '
'show up here.',
);
}
return ItemsListView<Item>(
showWebPreview: false,
refreshController: refreshControllerHistory,
@ -154,6 +144,14 @@ class _ProfileScreenState extends State<ProfileScreen>
}
},
builder: (context, favState) {
if (favState.favStories.isEmpty) {
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>(
showWebPreview:
preferenceState.showComplexStoryTile,
@ -306,6 +304,24 @@ class _ProfileScreenState extends State<ProfileScreen>
},
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(
title: const Text('Eye Candy'),
subtitle: const Text('some sort of magic.'),
@ -365,7 +381,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showAboutDialog(
context: context,
applicationName: 'Hacki',
applicationVersion: 'v0.1.7',
applicationVersion: 'v0.1.8',
applicationIcon: Image.asset(
Constants.hackiIconPath,
height: 50,
@ -453,7 +469,7 @@ class _ProfileScreenState extends State<ProfileScreen>
),
CustomChip(
label: 'Inbox : '
//ignore: lines_longer_than_80_chars
// ignore: lines_longer_than_80_chars
'${notificationState.unreadCommentsIds.length}',
selected: pageType == _PageType.notification,
onSelected: (val) {
@ -841,3 +857,28 @@ 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

@ -8,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
@ -142,15 +143,13 @@ class _StoryScreenState extends State<StoryScreen> {
return BlocConsumer<PostCubit, PostState>(
listener: (context, postState) {
if (postState.status == PostStatus.successful) {
editCubit.onReplySubmittedSuccessfully();
final verb =
editCubit.state.replyingTo == null ? 'updated' : 'submitted';
final msg = 'Comment $verb! ${(happyFaces..shuffle()).first}';
focusNode.unfocus();
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Comment submitted! ${(happyFaces..shuffle()).first}',
),
backgroundColor: Colors.orange,
));
showSnackBar(content: msg);
editCubit.onReplySubmittedSuccessfully();
context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
@ -178,10 +177,12 @@ class _StoryScreenState extends State<StoryScreen> {
builder: (context, state) {
return BlocConsumer<EditCubit, EditState>(
listenWhen: (previous, current) {
return previous.replyingTo != current.replyingTo;
return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited;
},
listener: (context, editState) {
if (editState.replyingTo != null) {
if (editState.replyingTo != null ||
editState.itemBeingEdited != null) {
if (editState.text == null) {
commentEditingController.clear();
} else {
@ -197,6 +198,7 @@ class _StoryScreenState extends State<StoryScreen> {
},
builder: (context, editState) {
final replyingTo = editCubit.state.replyingTo;
final editing = editCubit.state.itemBeingEdited;
return Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
@ -379,17 +381,34 @@ class _StoryScreenState extends State<StoryScreen> {
SizedBox(
height: topPadding,
),
InkWell(
onTap: () {
setState(() {
if (widget.story != replyingTo) {
commentEditingController.clear();
}
editCubit.onItemTapped(widget.story);
focusNode.requestFocus();
});
},
onLongPress: () => onMorePressed(widget.story),
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(
@ -479,7 +498,16 @@ class _StoryScreenState extends State<StoryScreen> {
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();
},
onMoreTapped: onMorePressed,
@ -524,6 +552,7 @@ class _StoryScreenState extends State<StoryScreen> {
child: ReplyBox(
focusNode: focusNode,
textEditingController: commentEditingController,
editing: editing,
replyingTo: replyingTo,
isLoading: postState.status == PostStatus.loading,
onSendTapped: onSendTapped,
@ -614,6 +643,9 @@ class _StoryScreenState extends State<StoryScreen> {
? const TextStyle(color: Colors.orange)
: null,
),
subtitle: item is Story
? Text(item.score.toString())
: null,
onTap: context.read<VoteCubit>().upvote,
),
ListTile(
@ -745,10 +777,7 @@ class _StoryScreenState extends State<StoryScreen> {
}).then((yesTapped) {
if (yesTapped ?? false) {
context.read<AuthBloc>().add(AuthFlag(item: item));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Comment flagged!'),
backgroundColor: Colors.orange,
));
showSnackBar(content: 'Comment flagged!');
}
});
}
@ -817,10 +846,7 @@ class _StoryScreenState extends State<StoryScreen> {
} else {
context.read<BlocklistCubit>().addToBlocklist(item.by);
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('User ${isBlocked ? 'unblocked' : 'blocked'}!'),
backgroundColor: Colors.orange,
));
showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
}
});
}
@ -828,7 +854,9 @@ class _StoryScreenState extends State<StoryScreen> {
void onSendTapped() {
final authBloc = context.read<AuthBloc>();
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) {
final text = commentEditingController.text;
@ -836,7 +864,9 @@ class _StoryScreenState extends State<StoryScreen> {
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);
}
} else {
@ -857,12 +887,7 @@ class _StoryScreenState extends State<StoryScreen> {
listener: (context, state) {
if (state.isLoggedIn) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Logged in successfully! $happyFace'),
backgroundColor: Colors.orange,
),
);
showSnackBar(content: 'Logged in successfully! $happyFace');
}
},
builder: (context, state) {

View File

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

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart';
@ -14,6 +16,7 @@ class CommentTile extends StatelessWidget {
required this.comment,
required this.onReplyTapped,
required this.onMoreTapped,
required this.onEditTapped,
required this.onStoryLinkTapped,
this.loadKids = true,
this.level = 0,
@ -25,6 +28,7 @@ class CommentTile extends StatelessWidget {
final bool loadKids;
final Function(Comment) onReplyTapped;
final Function(Comment) onMoreTapped;
final Function(Comment) onEditTapped;
final Function(String) onStoryLinkTapped;
@override
@ -68,14 +72,20 @@ class CommentTile extends StatelessWidget {
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
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(
onPressed: (_) => onMoreTapped(comment),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.more_horiz,
label: 'More',
),
],
),
@ -84,8 +94,10 @@ class CommentTile extends StatelessWidget {
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () =>
context.read<CommentsCubit>().collapse(),
onTap: () {
HapticFeedback.lightImpact();
context.read<CommentsCubit>().collapse();
},
child: Padding(
padding: const EdgeInsets.only(
left: 6, right: 6, top: 6),
@ -192,6 +204,7 @@ class CommentTile extends StatelessWidget {
myUsername: myUsername,
onReplyTapped: onReplyTapped,
onMoreTapped: onMoreTapped,
onEditTapped: onEditTapped,
level: level + 1,
onStoryLinkTapped: onStoryLinkTapped,
),

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/custom_circular_progress_indicator.dart';
import 'package:hacki/screens/widgets/story_tile.dart';
@ -18,6 +20,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
required this.refreshController,
this.enablePullDown = true,
this.pinnable = false,
this.markReadStories = false,
this.onRefresh,
this.onLoadMore,
this.onPinned,
@ -27,6 +30,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final bool showWebPreview;
final bool enablePullDown;
final bool markReadStories;
/// Whether story tiles can be pinned to the top.
final bool pinnable;
@ -44,6 +48,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
children: [
if (header != null) header!,
...items.map((e) {
final wasRead =
context.read<CacheCubit>().state.storiesReadStatus[e.id] ?? false;
if (e is Story) {
return [
FadeIn(
@ -72,6 +78,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
story: e,
onTap: () => onTap(e),
showWebPreview: showWebPreview,
wasRead: markReadStories && wasRead,
),
),
),

View File

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

View File

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

View File

@ -1,6 +1,6 @@
name: hacki
description: A Hacker News reader.
version: 0.1.7+24
version: 0.1.8+25
publish_to: none
environment:
@ -34,6 +34,7 @@ dependencies:
font_awesome_flutter: ^9.2.0
gbk_codec: ^0.4.0
get_it: 7.2.0
hive: ^2.0.6
html: ^0.15.0
html_unescape: ^2.0.0
http: ^0.13.3