Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
553335d8dc | |||
747491944f |
@ -11,7 +11,7 @@ A simple noiseless [Hacker News](https://news.ycombinator.com/) reader made with
|
||||
|
||||
<noscript><a href="https://liberapay.com/jfeng_for_open_source/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
|
||||
|
||||
[<img src="images/app_store_badge.png" height="50">](https://apps.apple.com/us/app/hacki/id1602043763) [<img src="images/google_play_badge.png" height="50">](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US)
|
||||
[<img src="assets/images/app_store_badge.png" height="50">](https://apps.apple.com/us/app/hacki/id1602043763) [<img src="assets/images/google_play_badge.png" height="50">](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US)
|
||||
|
||||
|
||||
Features:
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
BIN
assets/images/comment_tile_left_slide.png
Normal file
After Width: | Height: | Size: 548 KiB |
BIN
assets/images/comment_tile_right_slide.png
Normal file
After Width: | Height: | Size: 571 KiB |
BIN
assets/images/comment_tile_top_tap.png
Normal file
After Width: | Height: | Size: 592 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
4
fastlane/metadata/android/en-US/changelogs/41.txt
Normal file
@ -0,0 +1,4 @@
|
||||
- You can now participate in polls.
|
||||
- Pick up where you left off.
|
||||
- Swipe left on comment tile to view its parents without scrolling all the way up.
|
||||
- Huge performance boost.
|
4
fastlane/metadata/android/en-US/changelogs/42.txt
Normal file
@ -0,0 +1,4 @@
|
||||
- You can now participate in polls.
|
||||
- Pick up where you left off.
|
||||
- Swipe left on comment tile to view its parents without scrolling all the way up.
|
||||
- Huge performance boost.
|
@ -367,7 +367,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -376,7 +376,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.6;
|
||||
MARKETING_VERSION = 0.2.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -503,7 +503,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -512,7 +512,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.6;
|
||||
MARKETING_VERSION = 0.2.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -533,7 +533,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -542,7 +542,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.6;
|
||||
MARKETING_VERSION = 0.2.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
@ -10,8 +10,15 @@ abstract class Constants {
|
||||
static const String googlePlayLink =
|
||||
'https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US';
|
||||
|
||||
static const String hackerNewsLogoPath = 'images/hacker_news_logo.png';
|
||||
static const String hackiIconPath = 'images/hacki_icon.png';
|
||||
static const String _imagePath = 'assets/images';
|
||||
static const String hackerNewsLogoPath = '$_imagePath/hacker_news_logo.png';
|
||||
static const String hackiIconPath = '$_imagePath/hacki_icon.png';
|
||||
static const String commentTileLeftSlidePath =
|
||||
'$_imagePath/comment_tile_left_slide.png';
|
||||
static const String commentTileRightSlidePath =
|
||||
'$_imagePath/comment_tile_right_slide.png';
|
||||
static const String commentTileTopTapPath =
|
||||
'$_imagePath/comment_tile_top_tap.png';
|
||||
|
||||
/// Feature ids for feature discovery.
|
||||
static const String featureAddStoryToFavList = 'add_story_to_fav_list';
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
@ -18,5 +19,8 @@ Future<void> setUpLocator() async {
|
||||
..registerSingleton<CacheRepository>(CacheRepository())
|
||||
..registerSingleton<CacheService>(CacheService())
|
||||
..registerSingleton<LocalNotification>(LocalNotification())
|
||||
..registerSingleton<Logger>(Logger());
|
||||
..registerSingleton<Logger>(Logger())
|
||||
..registerSingleton<RouteObserver<ModalRoute<dynamic>>>(
|
||||
RouteObserver<ModalRoute<dynamic>>(),
|
||||
);
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
@ -15,21 +17,74 @@ class CollapseCubit extends Cubit<CollapseState> {
|
||||
|
||||
final int _commentId;
|
||||
final CacheService _cacheService;
|
||||
late final StreamSubscription<Map<int, Set<int>>> _streamSubscription;
|
||||
|
||||
void init() {
|
||||
_streamSubscription =
|
||||
_cacheService.hiddenComments.listen(hiddenCommentsStreamListener);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
collapsedCount: _cacheService.totalHidden(_commentId),
|
||||
collapsed: _cacheService.isCollapsed(_commentId),
|
||||
hidden: _cacheService.isHidden(_commentId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void collapse() {
|
||||
_cacheService.updateCollapsedComments(_commentId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
collapsed: !state.collapsed,
|
||||
),
|
||||
);
|
||||
if (state.collapsed) {
|
||||
_cacheService.uncollapse(_commentId);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
collapsed: false,
|
||||
collapsedCount: 0,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final int count = _cacheService.collapse(_commentId);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
collapsed: true,
|
||||
collapsedCount: state.collapsed ? 0 : count,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void hiddenCommentsStreamListener(Map<int, Set<int>> event) {
|
||||
for (final int key in event.keys) {
|
||||
if (key == _commentId && !isClosed) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
collapsedCount: event[key]?.length ?? 0,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (final Set<int> val in event.values) {
|
||||
if (val.contains(_commentId) && !isClosed) {
|
||||
emit(
|
||||
state.copyWith(hidden: true),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
state.copyWith(hidden: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _streamSubscription.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,37 @@
|
||||
part of 'collapse_cubit.dart';
|
||||
|
||||
class CollapseState extends Equatable {
|
||||
const CollapseState({required this.collapsed});
|
||||
const CollapseState({
|
||||
required this.collapsed,
|
||||
required this.hidden,
|
||||
required this.collapsedCount,
|
||||
});
|
||||
|
||||
const CollapseState.init() : collapsed = false;
|
||||
const CollapseState.init()
|
||||
: collapsed = false,
|
||||
hidden = false,
|
||||
collapsedCount = 0;
|
||||
|
||||
final bool collapsed;
|
||||
final bool hidden;
|
||||
final int collapsedCount;
|
||||
|
||||
CollapseState copyWith({bool? collapsed}) {
|
||||
CollapseState copyWith({
|
||||
bool? collapsed,
|
||||
bool? hidden,
|
||||
int? collapsedCount,
|
||||
}) {
|
||||
return CollapseState(
|
||||
collapsed: collapsed ?? this.collapsed,
|
||||
hidden: hidden ?? this.hidden,
|
||||
collapsedCount: collapsedCount ?? this.collapsedCount,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[collapsed];
|
||||
List<Object?> get props => <Object?>[
|
||||
collapsed,
|
||||
hidden,
|
||||
collapsedCount,
|
||||
];
|
||||
}
|
||||
|
@ -153,8 +153,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
|
||||
void _onCommentFetched(Comment? comment) {
|
||||
if (comment != null) {
|
||||
_cacheService.cacheComment(comment);
|
||||
_sembastRepository.saveComment(comment);
|
||||
_cacheService
|
||||
..addKid(comment.id, to: comment.parent)
|
||||
..cacheComment(comment);
|
||||
_sembastRepository.cacheComment(comment);
|
||||
final List<Comment> updatedComments = <Comment>[
|
||||
...state.comments,
|
||||
comment
|
||||
|
@ -97,31 +97,35 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
}
|
||||
}
|
||||
|
||||
await _fetchReplies().whenComplete(() {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: NotificationStatus.loaded,
|
||||
),
|
||||
);
|
||||
_initializeTimer();
|
||||
}).onError((Object? error, StackTrace stackTrace) {
|
||||
_initializeTimer();
|
||||
return null;
|
||||
});
|
||||
await _fetchReplies().whenComplete(_initializeTimer);
|
||||
}
|
||||
|
||||
void markAsRead(int id) {
|
||||
if (state.unreadCommentsIds.contains(id)) {
|
||||
final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds]
|
||||
..remove(id);
|
||||
_preferenceRepository.updateUnreadCommentsIds(updatedUnreadIds);
|
||||
emit(state.copyWith(unreadCommentsIds: updatedUnreadIds));
|
||||
}
|
||||
Future.doWhile(() {
|
||||
if (state.status != NotificationStatus.loading) {
|
||||
if (state.unreadCommentsIds.contains(id)) {
|
||||
final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds]
|
||||
..remove(id);
|
||||
_preferenceRepository.updateUnreadCommentsIds(updatedUnreadIds);
|
||||
emit(state.copyWith(unreadCommentsIds: updatedUnreadIds));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void markAllAsRead() {
|
||||
emit(state.copyWith(unreadCommentsIds: <int>[]));
|
||||
_preferenceRepository.updateUnreadCommentsIds(<int>[]);
|
||||
Future.doWhile(() {
|
||||
if (state.status != NotificationStatus.loading) {
|
||||
emit(state.copyWith(unreadCommentsIds: <int>[]));
|
||||
_preferenceRepository.updateUnreadCommentsIds(<int>[]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
@ -134,22 +138,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
_timer?.cancel();
|
||||
|
||||
await _fetchReplies().whenComplete(() {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: NotificationStatus.loaded,
|
||||
),
|
||||
);
|
||||
_initializeTimer();
|
||||
}).onError((Object? error, StackTrace stackTrace) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: NotificationStatus.loaded,
|
||||
),
|
||||
);
|
||||
_initializeTimer();
|
||||
return null;
|
||||
});
|
||||
await _fetchReplies().whenComplete(_initializeTimer);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -247,6 +236,10 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}).whenComplete(
|
||||
() => emit(
|
||||
state.copyWith(status: NotificationStatus.loaded),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,20 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
|
||||
part 'split_view_state.dart';
|
||||
|
||||
class SplitViewCubit extends Cubit<SplitViewState> {
|
||||
SplitViewCubit() : super(const SplitViewState.init());
|
||||
SplitViewCubit({CacheService? cacheService})
|
||||
: _cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
super(const SplitViewState.init());
|
||||
|
||||
final CacheService _cacheService;
|
||||
|
||||
void updateStoryScreenArgs(StoryScreenArgs args) {
|
||||
_cacheService.resetCollapsedComments();
|
||||
emit(state.copyWith(storyScreenArgs: args));
|
||||
}
|
||||
|
||||
|
@ -24,14 +24,14 @@ class TimeMachineCubit extends Cubit<TimeMachineState> {
|
||||
|
||||
final List<Comment> parents = <Comment>[];
|
||||
Comment? parent = _cacheService.getComment(comment.parent);
|
||||
parent ??= await _sembastRepository.getComment(id: comment.parent);
|
||||
parent ??= await _sembastRepository.getCachedComment(id: comment.parent);
|
||||
|
||||
while (parent != null) {
|
||||
parents.insert(0, parent);
|
||||
|
||||
final int parentId = parent.parent;
|
||||
parent = _cacheService.getComment(parentId);
|
||||
parent ??= await _sembastRepository.getComment(id: parentId);
|
||||
parent ??= await _sembastRepository.getCachedComment(id: parentId);
|
||||
}
|
||||
|
||||
emit(state.copyWith(parents: parents));
|
||||
|
@ -1,4 +1,5 @@
|
||||
export 'date_time_extension.dart';
|
||||
export 'int_extension.dart';
|
||||
export 'list_extension.dart';
|
||||
export 'object_extension.dart';
|
||||
export 'state_extension.dart';
|
||||
|
5
lib/extensions/int_extension.dart
Normal file
@ -0,0 +1,5 @@
|
||||
extension IntExtension on int {
|
||||
Iterable<int> to(int other, {bool inclusive = true}) => other > this
|
||||
? <int>[for (int i = this; i < other; i++) i, if (inclusive) other]
|
||||
: <int>[for (int i = this; i > other; i--) i, if (inclusive) other];
|
||||
}
|
@ -208,6 +208,9 @@ class HackiApp extends StatelessWidget {
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: useTrueDark ? trueDarkTheme : theme,
|
||||
navigatorKey: navigatorKey,
|
||||
navigatorObservers: <NavigatorObserver>[
|
||||
locator.get<RouteObserver<ModalRoute<dynamic>>>(),
|
||||
],
|
||||
onGenerateRoute: CustomRouter.onGenerateRoute,
|
||||
initialRoute: HomeScreen.routeName,
|
||||
),
|
||||
|
@ -1,6 +1,8 @@
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
/// [CacheRepository] is for storing stories and comments for offline reading.
|
||||
/// It's using [Hive] as its database which is being stored in temp directory.
|
||||
class CacheRepository {
|
||||
CacheRepository({
|
||||
Future<Box<List<int>>>? storyIdBox,
|
||||
@ -116,4 +118,10 @@ class CacheRepository {
|
||||
final LazyBox<Map<dynamic, dynamic>> box = await _commentBox;
|
||||
return box.clear();
|
||||
}
|
||||
|
||||
Future<int> deleteAll() async {
|
||||
return deleteAllStoryIds()
|
||||
.whenComplete(deleteAllStories)
|
||||
.whenComplete(deleteAllComments);
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ class PreferenceRepository {
|
||||
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
|
||||
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
|
||||
static const String _lastReadStoryIdKey = 'lastReadStoryId';
|
||||
static const String _isFirstLaunchKey = 'isFirstLaunch';
|
||||
|
||||
static const String _notificationModeKey = 'notificationMode';
|
||||
static const String _readerModeKey = 'readerMode';
|
||||
@ -44,6 +45,7 @@ class PreferenceRepository {
|
||||
static const bool _trueDarkModeDefaultValue = false;
|
||||
static const bool _readerModeDefaultValue = true;
|
||||
static const bool _markReadStoriesModeDefaultValue = true;
|
||||
static const bool _isFirstLaunchKeyDefaultValue = true;
|
||||
|
||||
final SyncedSharedPreferences _syncedPrefs;
|
||||
final Future<SharedPreferences> _prefs;
|
||||
@ -55,6 +57,16 @@ class PreferenceRepository {
|
||||
|
||||
Future<String?> get password async => _secureStorage.read(key: _passwordKey);
|
||||
|
||||
Future<bool> get isFirstLaunch async {
|
||||
final SharedPreferences prefs = await _prefs;
|
||||
final bool val =
|
||||
prefs.getBool(_isFirstLaunchKey) ?? _isFirstLaunchKeyDefaultValue;
|
||||
|
||||
await prefs.setBool(_isFirstLaunchKey, false);
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
Future<List<String>> get blocklist async => _prefs.then(
|
||||
(SharedPreferences prefs) =>
|
||||
prefs.getStringList(_blocklistKey) ?? <String>[],
|
||||
|
@ -6,6 +6,8 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sembast/sembast.dart';
|
||||
import 'package:sembast/sembast_io.dart';
|
||||
|
||||
/// [SembastRepository] is for storing stories and comments for faster loading.
|
||||
/// It's using Sembast as its database which is being stored in doc directory.
|
||||
class SembastRepository {
|
||||
SembastRepository({Database? database}) {
|
||||
if (database == null) {
|
||||
@ -18,6 +20,7 @@ class SembastRepository {
|
||||
Database? _database;
|
||||
List<int>? _idsOfCommentsRepliedToMe;
|
||||
|
||||
static const String _cachedCommentsKey = 'cachedComments';
|
||||
static const String _commentsKey = 'comments';
|
||||
static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe';
|
||||
|
||||
@ -31,6 +34,38 @@ class SembastRepository {
|
||||
return db;
|
||||
}
|
||||
|
||||
//#region Cached comments for time machine feature.
|
||||
Future<Map<String, Object?>> cacheComment(Comment comment) async {
|
||||
final Database db = _database ?? await initializeDatabase();
|
||||
final StoreRef<int, Map<String, Object?>> store =
|
||||
intMapStoreFactory.store(_cachedCommentsKey);
|
||||
return store.record(comment.id).put(db, comment.toJson());
|
||||
}
|
||||
|
||||
Future<Comment?> getCachedComment({required int id}) async {
|
||||
final Database db = _database ?? await initializeDatabase();
|
||||
final StoreRef<int, Map<String, Object?>> store =
|
||||
intMapStoreFactory.store(_cachedCommentsKey);
|
||||
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
|
||||
await store.record(id).getSnapshot(db);
|
||||
if (snapshot != null) {
|
||||
final Comment comment = Comment.fromJson(snapshot.value);
|
||||
return comment;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> deleteAllCachedComments() async {
|
||||
final Database db = _database ?? await initializeDatabase();
|
||||
final StoreRef<int, Map<String, Object?>> store =
|
||||
intMapStoreFactory.store(_cachedCommentsKey);
|
||||
return store.delete(db);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Saved comments for notification feature.
|
||||
Future<Map<String, Object?>> saveComment(Comment comment) async {
|
||||
final Database db = _database ?? await initializeDatabase();
|
||||
final StoreRef<int, Map<String, Object?>> store =
|
||||
@ -137,6 +172,8 @@ class SembastRepository {
|
||||
return kids;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
Future<FileSystemEntity> deleteAll() async {
|
||||
final Directory dir = await getApplicationDocumentsDirectory();
|
||||
await dir.create(recursive: true);
|
||||
|
@ -1,5 +1,6 @@
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:badges/badges.dart';
|
||||
@ -41,11 +42,26 @@ class HomeScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin, RouteAware {
|
||||
final CacheService cacheService = locator.get<CacheService>();
|
||||
final Throttle featureDiscoveryDismissThrottle = Throttle(
|
||||
delay: _throttleDelay,
|
||||
);
|
||||
|
||||
late final TabController tabController;
|
||||
int currentIndex = 0;
|
||||
|
||||
static const Duration _throttleDelay = Duration(seconds: 1);
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
if (context.read<StoriesBloc>().deviceScreenType ==
|
||||
DeviceScreenType.mobile) {
|
||||
cacheService.resetCollapsedComments();
|
||||
}
|
||||
super.didPopNext();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -86,14 +102,24 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
});
|
||||
}
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
const <String>{
|
||||
Constants.featureLogIn,
|
||||
},
|
||||
);
|
||||
});
|
||||
SchedulerBinding.instance
|
||||
..addPostFrameCallback((_) {
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
const <String>{
|
||||
Constants.featureLogIn,
|
||||
},
|
||||
);
|
||||
})
|
||||
..addPostFrameCallback((_) {
|
||||
final ModalRoute<dynamic>? route = ModalRoute.of(context);
|
||||
|
||||
if (route == null) return;
|
||||
|
||||
locator
|
||||
.get<RouteObserver<ModalRoute<dynamic>>>()
|
||||
.subscribe(this, route);
|
||||
});
|
||||
|
||||
tabController = TabController(vsync: this, length: 6)
|
||||
..addListener(() {
|
||||
@ -258,6 +284,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
child: DescribedFeatureOverlay(
|
||||
onBackgroundTap: onFeatureDiscoveryDismissed,
|
||||
onDismiss: onFeatureDiscoveryDismissed,
|
||||
onComplete: () async {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
showOnboarding();
|
||||
return true;
|
||||
},
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: const Icon(
|
||||
@ -372,9 +404,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
Future<bool> onFeatureDiscoveryDismissed() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
showSnackBar(content: 'Tap on icon to continue');
|
||||
featureDiscoveryDismissThrottle.run(() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
showSnackBar(content: 'Tap on icon to continue');
|
||||
});
|
||||
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
|
||||
@ -414,7 +449,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
if (!offlineReading && (isJobWithLink || (showWebFirst && !hasRead))) {
|
||||
LinkUtil.launchUrl(story.url, useReader: useReader);
|
||||
cacheService.store(story.id);
|
||||
}
|
||||
|
||||
context.read<StoriesBloc>().add(
|
||||
@ -423,6 +457,16 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showOnboarding() {
|
||||
Navigator.push<dynamic>(
|
||||
context,
|
||||
MaterialPageRoute<dynamic>(
|
||||
builder: (BuildContext context) => const OnboardingView(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileHomeScreenBuilder extends StatelessWidget {
|
||||
|
@ -4,6 +4,7 @@ import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
@ -375,6 +376,12 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
onTap: showThemeSettingDialog,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Clear Data',
|
||||
),
|
||||
onTap: showClearDataDialog,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('About'),
|
||||
subtitle:
|
||||
@ -383,7 +390,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'Hacki',
|
||||
applicationVersion: 'v0.2.6',
|
||||
applicationVersion: 'v0.2.7',
|
||||
applicationIcon: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
@ -609,6 +616,61 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void showClearDataDialog() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return AlertDialog(
|
||||
title: const Text('Clear Data?'),
|
||||
content: const Text(
|
||||
'Clear all cached images, stories and comments.',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
locator
|
||||
.get<SembastRepository>()
|
||||
.deleteAllCachedComments()
|
||||
.whenComplete(
|
||||
locator.get<SembastRepository>().deleteAllCachedComments,
|
||||
)
|
||||
.whenComplete(
|
||||
locator.get<PreferenceRepository>().clearAllReadStories,
|
||||
)
|
||||
.whenComplete(
|
||||
DefaultCacheManager().emptyCache,
|
||||
)
|
||||
.whenComplete(() {
|
||||
showSnackBar(content: 'Data cleared!');
|
||||
});
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(Colors.deepOrange),
|
||||
),
|
||||
child: const Text(
|
||||
'Yes',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void onCommentTapped(Comment comment, {VoidCallback? then}) {
|
||||
throttle.run(() {
|
||||
locator
|
||||
|
@ -132,8 +132,17 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
initialRefreshStatus: RefreshStatus.refreshing,
|
||||
);
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final Throttle throttle = Throttle(delay: const Duration(seconds: 2));
|
||||
final String happyFace = Constants.happyFaces.pickRandomly()!;
|
||||
final Throttle storyLinkTapThrottle = Throttle(
|
||||
delay: _storyLinkTapThrottleDelay,
|
||||
);
|
||||
final Throttle featureDiscoveryDismissThrottle = Throttle(
|
||||
delay: _featureDiscoveryDismissThrottleDelay,
|
||||
);
|
||||
|
||||
static const Duration _storyLinkTapThrottleDelay = Duration(seconds: 2);
|
||||
static const Duration _featureDiscoveryDismissThrottleDelay =
|
||||
Duration(seconds: 1);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -164,7 +173,8 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
refreshController.dispose();
|
||||
commentEditingController.dispose();
|
||||
scrollController.dispose();
|
||||
throttle.dispose();
|
||||
storyLinkTapThrottle.dispose();
|
||||
featureDiscoveryDismissThrottle.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -559,9 +569,11 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
}
|
||||
|
||||
Future<bool> onFeatureDiscoveryDismissed() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
showSnackBar(content: 'Tap on icon to continue');
|
||||
featureDiscoveryDismissThrottle.run(() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
showSnackBar(content: 'Tap on icon to continue');
|
||||
});
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
|
||||
@ -615,10 +627,10 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
in state.parents) ...<Widget>[
|
||||
CommentTile(
|
||||
comment: c,
|
||||
loadKids: false,
|
||||
myUsername:
|
||||
context.read<AuthBloc>().state.username,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
actionable: false,
|
||||
),
|
||||
const Divider(
|
||||
height: 0,
|
||||
@ -645,7 +657,7 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
final String match = regex.stringMatch(link) ?? '';
|
||||
final int? id = int.tryParse(match);
|
||||
if (id != null) {
|
||||
throttle.run(() {
|
||||
storyLinkTapThrottle.run(() {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchParentStory(id: id)
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@ -26,6 +28,10 @@ class FavIconButton extends StatelessWidget {
|
||||
icon: DescribedFeatureOverlay(
|
||||
onBackgroundTap: onBackgroundTap,
|
||||
onDismiss: onDismiss,
|
||||
onComplete: () async {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
return true;
|
||||
},
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: Icon(
|
||||
|
@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
@ -21,6 +24,10 @@ class LinkIconButton extends StatelessWidget {
|
||||
icon: DescribedFeatureOverlay(
|
||||
onBackgroundTap: onBackgroundTap,
|
||||
onDismiss: onDismiss,
|
||||
onComplete: () async {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
return true;
|
||||
},
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: const Icon(
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
@ -33,6 +34,10 @@ class PinIconButton extends StatelessWidget {
|
||||
icon: DescribedFeatureOverlay(
|
||||
onBackgroundTap: onBackgroundTap,
|
||||
onDismiss: onDismiss,
|
||||
onComplete: () async {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
return true;
|
||||
},
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: Icon(
|
||||
|
@ -5,6 +5,7 @@ import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
@ -19,7 +20,7 @@ class CommentTile extends StatelessWidget {
|
||||
this.onEditTapped,
|
||||
this.onTimeMachineActivated,
|
||||
this.opUsername,
|
||||
this.loadKids = true,
|
||||
this.actionable = true,
|
||||
this.level = 0,
|
||||
});
|
||||
|
||||
@ -27,7 +28,7 @@ class CommentTile extends StatelessWidget {
|
||||
final String? opUsername;
|
||||
final Comment comment;
|
||||
final int level;
|
||||
final bool loadKids;
|
||||
final bool actionable;
|
||||
final Function(Comment)? onReplyTapped;
|
||||
final Function(Comment)? onMoreTapped;
|
||||
final Function(Comment)? onEditTapped;
|
||||
@ -43,6 +44,8 @@ class CommentTile extends StatelessWidget {
|
||||
)..init(),
|
||||
child: BlocBuilder<CollapseCubit, CollapseState>(
|
||||
builder: (BuildContext context, CollapseState state) {
|
||||
if (actionable && state.hidden) return const SizedBox.shrink();
|
||||
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
builder: (BuildContext context, PreferenceState prefState) {
|
||||
return BlocBuilder<BlocklistCubit, BlocklistState>(
|
||||
@ -56,7 +59,7 @@ class CommentTile extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Slidable(
|
||||
startActionPane: loadKids
|
||||
startActionPane: actionable
|
||||
? ActionPane(
|
||||
motion: const StretchMotion(),
|
||||
children: <Widget>[
|
||||
@ -90,7 +93,7 @@ class CommentTile extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
: null,
|
||||
endActionPane: loadKids && level != 0
|
||||
endActionPane: actionable && level != 0
|
||||
? ActionPane(
|
||||
motion: const StretchMotion(),
|
||||
children: <Widget>[
|
||||
@ -104,16 +107,17 @@ class CommentTile extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
},
|
||||
child: Padding(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (actionable) {
|
||||
HapticFeedback.lightImpact();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 6,
|
||||
right: 6,
|
||||
@ -146,86 +150,95 @@ class CommentTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (comment.deleted)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(
|
||||
'deleted',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
if (comment.deleted)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'deleted',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (comment.dead)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'dead',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (blocklistState.blocklist
|
||||
.contains(comment.by))
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'blocked',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (actionable && state.collapsed)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'collapsed '
|
||||
'(${state.collapsedCount + 1})',
|
||||
style: const TextStyle(
|
||||
color: Colors.orangeAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 6,
|
||||
bottom: 12,
|
||||
),
|
||||
child: SelectableLinkify(
|
||||
key: ObjectKey(comment),
|
||||
text: comment.text,
|
||||
style: TextStyle(
|
||||
fontSize: MediaQuery.of(context)
|
||||
.textScaleFactor *
|
||||
15,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
fontSize: MediaQuery.of(context)
|
||||
.textScaleFactor *
|
||||
15,
|
||||
color: Colors.orange,
|
||||
),
|
||||
onOpen: (LinkableElement link) {
|
||||
if (link.url.contains(
|
||||
'news.ycombinator.com/item',
|
||||
)) {
|
||||
onStoryLinkTapped.call(link.url);
|
||||
} else {
|
||||
LinkUtil.launchUrl(link.url);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (comment.dead)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(
|
||||
'dead',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (blocklistState.blocklist
|
||||
.contains(comment.by))
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(
|
||||
'blocked',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (state.collapsed)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'collapsed',
|
||||
style:
|
||||
TextStyle(color: Colors.orangeAccent),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 6,
|
||||
bottom: 12,
|
||||
),
|
||||
child: SelectableLinkify(
|
||||
key: ObjectKey(comment),
|
||||
text: comment.text,
|
||||
style: TextStyle(
|
||||
fontSize: MediaQuery.of(context)
|
||||
.textScaleFactor *
|
||||
15,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
fontSize: MediaQuery.of(context)
|
||||
.textScaleFactor *
|
||||
15,
|
||||
color: Colors.orange,
|
||||
),
|
||||
onOpen: (LinkableElement link) {
|
||||
if (link.url.contains(
|
||||
'news.ycombinator.com/item',
|
||||
)) {
|
||||
onStoryLinkTapped.call(link.url);
|
||||
} else {
|
||||
LinkUtil.launchUrl(link.url);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(
|
||||
height: 0,
|
||||
),
|
||||
const Divider(
|
||||
height: 0,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -251,10 +264,7 @@ class CommentTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
for (final int i in List<int>.generate(
|
||||
level,
|
||||
(int index) => level - index,
|
||||
)) {
|
||||
for (final int i in level.to(0, inclusive: false)) {
|
||||
final Color wrapperBorderColor = _getColor(i);
|
||||
final bool shouldHighlight = isMyComment && i == level;
|
||||
wrapper = Container(
|
||||
|
138
lib/screens/widgets/onboarding_view.dart
Normal file
@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class OnboardingView extends StatefulWidget {
|
||||
const OnboardingView({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<OnboardingView> createState() => _OnboardingViewState();
|
||||
}
|
||||
|
||||
class _OnboardingViewState extends State<OnboardingView> {
|
||||
final PageController pageController = PageController();
|
||||
final Throttle throttle = Throttle(delay: _throttleDelay);
|
||||
|
||||
static const Duration _throttleDelay = Duration(milliseconds: 100);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.orange
|
||||
: Theme.of(context).canvasColor,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.orange
|
||||
: null,
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
Positioned(
|
||||
top: 40,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SizedBox(
|
||||
height: 550,
|
||||
child: PageView(
|
||||
controller: pageController,
|
||||
scrollDirection: Axis.vertical,
|
||||
children: const <Widget>[
|
||||
_PageViewChild(
|
||||
path: Constants.commentTileRightSlidePath,
|
||||
description: 'Swipe right to leave a comment or vote.',
|
||||
),
|
||||
_PageViewChild(
|
||||
path: Constants.commentTileLeftSlidePath,
|
||||
description: 'Swipe left to view all the parent comments.',
|
||||
),
|
||||
_PageViewChild(
|
||||
path: Constants.commentTileTopTapPath,
|
||||
description: 'Tap on the top of comment tile to collapse.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
if (pageController.page! >= 2) {
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
throttle.run(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: SpringCurve.underDamped,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
primary: Colors.orange,
|
||||
padding: const EdgeInsets.all(18),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_drop_down_circle_outlined,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PageViewChild extends StatelessWidget {
|
||||
const _PageViewChild({
|
||||
Key? key,
|
||||
required this.path,
|
||||
required this.description,
|
||||
}) : super(key: key);
|
||||
|
||||
final String path;
|
||||
final String description;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
height: 400,
|
||||
child: Image.asset(path),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 24,
|
||||
),
|
||||
child: Text(
|
||||
description,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ export 'items_list_view.dart';
|
||||
export 'link_preview/link_preview.dart';
|
||||
export 'link_preview/link_preview.dart';
|
||||
export 'offline_banner.dart';
|
||||
export 'onboarding_view.dart';
|
||||
export 'spring_curve.dart';
|
||||
export 'stories_list_view.dart';
|
||||
export 'story_tile.dart';
|
||||
|
@ -1,25 +1,82 @@
|
||||
import 'package:hacki/models/models.dart' show Comment;
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
class CacheService {
|
||||
static final Set<int> _tappedStories = <int>{};
|
||||
static final Map<int, Comment> _comments = <int, Comment>{};
|
||||
static final Set<int> _commentsCollapsed = <int>{};
|
||||
static final Map<int, String> _drafts = <int, String>{};
|
||||
static final Map<int, Set<int>> _kids = <int, Set<int>>{};
|
||||
static final Set<int> _collapsed = <int>{};
|
||||
static final Map<int, Set<int>> _hidden = <int, Set<int>>{};
|
||||
final PublishSubject<Map<int, Set<int>>> _hiddenCommentsSubject =
|
||||
PublishSubject<Map<int, Set<int>>>();
|
||||
|
||||
bool isFirstTimeReading(int storyId) => !_tappedStories.contains(storyId);
|
||||
Stream<Map<int, Set<int>>> get hiddenComments =>
|
||||
_hiddenCommentsSubject.stream;
|
||||
|
||||
bool isCollapsed(int commentId) => _commentsCollapsed.contains(commentId);
|
||||
void addKid(int commentId, {required int to}) {
|
||||
_kids[to] = <int>{...?_kids[to], commentId};
|
||||
addIfParentIsHiddenOrCollapsed(commentId, to);
|
||||
}
|
||||
|
||||
void store(int storyId) => _tappedStories.add(storyId);
|
||||
int collapse(int commentId) {
|
||||
_collapsed.add(commentId);
|
||||
|
||||
void updateCollapsedComments(int commentId) {
|
||||
if (_commentsCollapsed.contains(commentId)) {
|
||||
_commentsCollapsed.remove(commentId);
|
||||
} else {
|
||||
_commentsCollapsed.add(commentId);
|
||||
Set<int> findHiddenComments(int commentId) {
|
||||
final Set<int> directKids = <int>{...?_kids[commentId]};
|
||||
final Set<int> temp = <int>{...directKids};
|
||||
|
||||
for (final int i in temp) {
|
||||
directKids.addAll(findHiddenComments(i));
|
||||
}
|
||||
|
||||
return directKids;
|
||||
}
|
||||
|
||||
final Set<int> hiddenComments = findHiddenComments(commentId);
|
||||
|
||||
_hidden[commentId] = hiddenComments;
|
||||
|
||||
_hiddenCommentsSubject.add(_hidden);
|
||||
|
||||
return hiddenComments.length;
|
||||
}
|
||||
|
||||
void uncollapse(int commentId) {
|
||||
_collapsed.remove(commentId);
|
||||
|
||||
_hidden.remove(commentId);
|
||||
|
||||
_hiddenCommentsSubject.add(_hidden);
|
||||
}
|
||||
|
||||
bool isHidden(int commentId) {
|
||||
for (final Set<int> val in _hidden.values) {
|
||||
if (val.contains(commentId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void addIfParentIsHiddenOrCollapsed(int commentId, int parentId) {
|
||||
for (final int key in _hidden.keys) {
|
||||
if (key == parentId || (_hidden[key]?.contains(parentId) ?? false)) {
|
||||
_hidden[key]?.add(commentId);
|
||||
_hiddenCommentsSubject.add(_hidden);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void resetCollapsedComments() {
|
||||
_kids.clear();
|
||||
_collapsed.clear();
|
||||
_hidden.clear();
|
||||
_hiddenCommentsSubject.add(_hidden);
|
||||
}
|
||||
|
||||
bool isCollapsed(int commentId) => _collapsed.contains(commentId);
|
||||
|
||||
int totalHidden(int commentId) => _hidden[commentId]?.length ?? 0;
|
||||
|
||||
void cacheComment(Comment comment) => _comments[comment.id] = comment;
|
||||
|
||||
Comment? getComment(int id) => _comments[id];
|
||||
|
28
pubspec.lock
@ -14,7 +14,7 @@ packages:
|
||||
name: adaptive_theme
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
version: "3.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -70,7 +70,7 @@ packages:
|
||||
name: cached_network_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
version: "3.2.1"
|
||||
cached_network_image_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -105,7 +105,7 @@ packages:
|
||||
name: chewie
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
version: "1.3.3"
|
||||
chewie_audio:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -256,9 +256,11 @@ packages:
|
||||
feature_discovery:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: feature_discovery
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
path: "."
|
||||
ref: flutter3_compatibility
|
||||
resolved-ref: "896306d04130f870c7ce99ce832fd977283b2392"
|
||||
url: "https://github.com/livinglist/feature_discovery"
|
||||
source: git
|
||||
version: "0.14.1"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
@ -294,7 +296,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.6.8"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
url: "https://pub.dartlang.org"
|
||||
@ -418,7 +420,7 @@ packages:
|
||||
name: flutter_slidable
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.2.1"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -761,9 +763,11 @@ packages:
|
||||
pull_to_refresh:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pull_to_refresh
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
path: "."
|
||||
ref: master
|
||||
resolved-ref: "6d7fbdd5b3cde242a474d66da312aac4aa892b9b"
|
||||
url: "https://github.com/livinglist/flutter_pulltorefresh"
|
||||
source: git
|
||||
version: "2.0.0"
|
||||
quiver:
|
||||
dependency: transitive
|
||||
@ -1255,4 +1259,4 @@ packages:
|
||||
version: "3.1.0"
|
||||
sdks:
|
||||
dart: ">=2.17.0 <3.0.0"
|
||||
flutter: ">=2.10.0"
|
||||
flutter: ">=3.0.0"
|
||||
|
24
pubspec.yaml
@ -1,26 +1,31 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 0.2.6+40
|
||||
version: 0.2.7+42
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^2.3.0
|
||||
adaptive_theme: ^3.0.0
|
||||
badges: ^2.0.2
|
||||
bloc: ^8.0.3
|
||||
cached_network_image: ^3.2.0
|
||||
cached_network_image: ^3.2.1
|
||||
clipboard: ^0.1.3
|
||||
collection:
|
||||
connectivity_plus: ^2.2.1
|
||||
dio: ^4.0.4
|
||||
equatable: 2.0.3
|
||||
fast_gbk: ^1.0.0
|
||||
feature_discovery: ^0.14.0
|
||||
# feature_discovery: ^0.14.0
|
||||
feature_discovery:
|
||||
git:
|
||||
url: https://github.com/livinglist/feature_discovery
|
||||
ref: flutter3_compatibility
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.0.1
|
||||
flutter_cache_manager: ^3.3.0
|
||||
flutter_fadein: ^2.0.0
|
||||
flutter_feather_icons: 2.0.0+1
|
||||
flutter_html: ^2.2.1
|
||||
@ -28,7 +33,7 @@ dependencies:
|
||||
flutter_linkify: ^5.0.2
|
||||
flutter_local_notifications: ^9.5.0
|
||||
flutter_secure_storage: ^5.0.2
|
||||
flutter_slidable: ^1.2.0
|
||||
flutter_slidable: ^1.2.1
|
||||
font_awesome_flutter: ^9.2.0
|
||||
gbk_codec: ^0.4.0
|
||||
get_it: 7.2.0
|
||||
@ -42,7 +47,11 @@ dependencies:
|
||||
path_provider: ^2.0.8
|
||||
path_provider_android: ^2.0.8
|
||||
path_provider_ios: ^2.0.8
|
||||
pull_to_refresh: ^2.0.0
|
||||
# pull_to_refresh: ^2.0.0
|
||||
pull_to_refresh:
|
||||
git:
|
||||
url: https://github.com/livinglist/flutter_pulltorefresh
|
||||
ref: master
|
||||
responsive_builder: ^0.4.2
|
||||
rxdart: ^0.27.3
|
||||
sembast: ^3.1.1+1
|
||||
@ -70,7 +79,6 @@ flutter:
|
||||
generate: true
|
||||
|
||||
assets:
|
||||
- images/hacki_icon.png
|
||||
- images/hacker_news_logo.png
|
||||
- assets/images/
|
||||
|
||||
|
||||
|