mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
d5ae60327d | |||
615a092c1e | |||
5a7699d866 | |||
56a9bab3f2 | |||
e9bbf46b4f | |||
10f503a6c0 | |||
582f3156b2 | |||
90fb45146f | |||
c19c54e762 | |||
70e5a84b63 | |||
3a51fa83f2 | |||
cb90751330 | |||
835ed7e841 | |||
125ccd2dd1 | |||
5b991c4287 |
3
fastlane/metadata/android/en-US/changelogs/135.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/135.txt
Normal file
@ -0,0 +1,3 @@
|
||||
- Return of true dark mode.
|
||||
- Better comment fetching strategy.
|
||||
- Minor UI fixes.
|
@ -114,6 +114,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
await _authRepository.logout();
|
||||
await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
|
||||
await _sembastRepository.deleteAll();
|
||||
await _sembastRepository.deleteCachedComments();
|
||||
}
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
super(const StoriesState.init()) {
|
||||
on<LoadStories>(
|
||||
onLoadStories,
|
||||
transformer: sequential(),
|
||||
transformer: concurrent(),
|
||||
);
|
||||
on<StoriesInitialize>(onInitialize);
|
||||
on<StoriesRefresh>(onRefresh);
|
||||
|
@ -3,6 +3,7 @@ import 'dart:math';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
@ -25,6 +26,7 @@ part 'comments_state.dart';
|
||||
class CommentsCubit extends Cubit<CommentsState> {
|
||||
CommentsCubit({
|
||||
required FilterCubit filterCubit,
|
||||
required PreferenceCubit preferenceCubit,
|
||||
required CollapseCache collapseCache,
|
||||
required bool isOfflineReading,
|
||||
required Item item,
|
||||
@ -34,8 +36,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
OfflineRepository? offlineRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
HackerNewsWebRepository? hackerNewsWebRepository,
|
||||
Logger? logger,
|
||||
}) : _filterCubit = filterCubit,
|
||||
_preferenceCubit = preferenceCubit,
|
||||
_collapseCache = collapseCache,
|
||||
_commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
_offlineRepository =
|
||||
@ -44,6 +48,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_hackerNewsRepository =
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(
|
||||
CommentsState.init(
|
||||
@ -55,11 +61,13 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
);
|
||||
|
||||
final FilterCubit _filterCubit;
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final CollapseCache _collapseCache;
|
||||
final CommentCache _commentCache;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final HackerNewsWebRepository _hackerNewsWebRepository;
|
||||
final Logger _logger;
|
||||
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
@ -75,6 +83,11 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
|
||||
<int, StreamSubscription<Comment>>{};
|
||||
|
||||
static Future<bool> get _isOnWifi async {
|
||||
final ConnectivityResult status = await Connectivity().checkConnectivity();
|
||||
return status == ConnectivityResult.wifi;
|
||||
}
|
||||
|
||||
@override
|
||||
void emit(CommentsState state) {
|
||||
if (!isClosed) {
|
||||
@ -86,6 +99,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
bool onlyShowTargetComment = false,
|
||||
bool useCommentCache = false,
|
||||
List<Comment>? targetAncestors,
|
||||
AppExceptionHandler? onError,
|
||||
bool fetchFromWeb = true,
|
||||
}) async {
|
||||
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
|
||||
emit(
|
||||
@ -143,11 +158,47 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
case FetchMode.eager:
|
||||
commentStream =
|
||||
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
switch (state.order) {
|
||||
case CommentsOrder.natural:
|
||||
final bool isOnWifi = await _isOnWifi;
|
||||
if (!isOnWifi && fetchFromWeb) {
|
||||
commentStream = _hackerNewsWebRepository
|
||||
.fetchCommentsStream(state.item)
|
||||
.handleError((dynamic e) {
|
||||
_streamSubscription?.cancel();
|
||||
|
||||
_logger.e(e);
|
||||
|
||||
switch (e.runtimeType) {
|
||||
case RateLimitedWithFallbackException:
|
||||
case PossibleParsingException:
|
||||
case BrowserNotRunningException:
|
||||
if (_preferenceCubit.state.devModeEnabled) {
|
||||
onError?.call(e as AppException);
|
||||
}
|
||||
|
||||
/// If fetching from web failed, fetch using API instead.
|
||||
refresh(onError: onError, fetchFromWeb: false);
|
||||
default:
|
||||
onError?.call(GenericException());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
commentStream =
|
||||
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache:
|
||||
useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
}
|
||||
case CommentsOrder.oldestFirst:
|
||||
case CommentsOrder.newestFirst:
|
||||
commentStream =
|
||||
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -158,7 +209,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
..onDone(_onDone);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
Future<void> refresh({
|
||||
required AppExceptionHandler? onError,
|
||||
bool fetchFromWeb = true,
|
||||
}) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CommentsStatus.inProgress,
|
||||
@ -195,14 +249,45 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final List<int> kids = _sortKids(updatedItem.kids);
|
||||
|
||||
late final Stream<Comment> commentStream;
|
||||
if (state.fetchMode == FetchMode.lazy) {
|
||||
commentStream = _hackerNewsRepository.fetchCommentsStream(
|
||||
ids: kids,
|
||||
);
|
||||
} else {
|
||||
commentStream = _hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
);
|
||||
|
||||
switch (state.fetchMode) {
|
||||
case FetchMode.lazy:
|
||||
commentStream = _hackerNewsRepository.fetchCommentsStream(ids: kids);
|
||||
case FetchMode.eager:
|
||||
switch (state.order) {
|
||||
case CommentsOrder.natural:
|
||||
final bool isOnWifi = await _isOnWifi;
|
||||
if (!isOnWifi && fetchFromWeb) {
|
||||
commentStream = _hackerNewsWebRepository
|
||||
.fetchCommentsStream(state.item)
|
||||
.handleError((dynamic e) {
|
||||
_logger.e(e);
|
||||
|
||||
switch (e.runtimeType) {
|
||||
case RateLimitedException:
|
||||
case PossibleParsingException:
|
||||
case BrowserNotRunningException:
|
||||
if (_preferenceCubit.state.devModeEnabled) {
|
||||
onError?.call(e as AppException);
|
||||
}
|
||||
|
||||
/// If fetching from web failed, fetch using API instead.
|
||||
refresh(onError: onError, fetchFromWeb: false);
|
||||
default:
|
||||
onError?.call(GenericException());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
commentStream = _hackerNewsRepository
|
||||
.fetchAllCommentsRecursivelyStream(ids: kids);
|
||||
}
|
||||
case CommentsOrder.oldestFirst:
|
||||
case CommentsOrder.newestFirst:
|
||||
commentStream =
|
||||
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
|
||||
ids: kids,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_streamSubscription = commentStream
|
||||
|
@ -2,11 +2,13 @@ import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
part 'fav_state.dart';
|
||||
|
||||
@ -17,6 +19,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
PreferenceRepository? preferenceRepository,
|
||||
HackerNewsRepository? hackerNewsRepository,
|
||||
HackerNewsWebRepository? hackerNewsWebRepository,
|
||||
Logger? logger,
|
||||
}) : _authBloc = authBloc,
|
||||
_authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_preferenceRepository =
|
||||
@ -25,6 +28,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
|
||||
_hackerNewsWebRepository =
|
||||
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(FavState.init()) {
|
||||
init();
|
||||
}
|
||||
@ -34,6 +38,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final HackerNewsRepository _hackerNewsRepository;
|
||||
final HackerNewsWebRepository _hackerNewsWebRepository;
|
||||
final Logger _logger;
|
||||
late final StreamSubscription<String>? _usernameSubscription;
|
||||
static const int _pageSize = 20;
|
||||
|
||||
@ -93,7 +98,9 @@ class FavCubit extends Cubit<FavState> {
|
||||
}
|
||||
|
||||
void removeFav(int id) {
|
||||
_preferenceRepository.removeFav(username: username, id: id);
|
||||
_preferenceRepository
|
||||
..removeFav(username: username, id: id)
|
||||
..removeFav(username: '', id: id);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -167,20 +174,31 @@ class FavCubit extends Cubit<FavState> {
|
||||
emit(FavState.init());
|
||||
}
|
||||
|
||||
Future<void> merge() async {
|
||||
Future<void> merge({
|
||||
required AppExceptionHandler onError,
|
||||
required VoidCallback onSuccess,
|
||||
}) async {
|
||||
if (_authBloc.state.isLoggedIn) {
|
||||
emit(state.copyWith(mergeStatus: Status.inProgress));
|
||||
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
|
||||
of: _authBloc.state.username,
|
||||
);
|
||||
final List<int> combinedIds = <int>[...ids, ...state.favIds];
|
||||
final LinkedHashSet<int> mergedIds = LinkedHashSet<int>.from(combinedIds);
|
||||
await _preferenceRepository.overwriteFav(
|
||||
username: username,
|
||||
ids: mergedIds,
|
||||
);
|
||||
emit(state.copyWith(mergeStatus: Status.success));
|
||||
refresh();
|
||||
try {
|
||||
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
|
||||
of: _authBloc.state.username,
|
||||
);
|
||||
_logger.d('fetched ${ids.length} favorite items from HN.');
|
||||
final List<int> combinedIds = <int>[...ids, ...state.favIds];
|
||||
final LinkedHashSet<int> mergedIds =
|
||||
LinkedHashSet<int>.from(combinedIds);
|
||||
await _preferenceRepository.overwriteFav(
|
||||
username: username,
|
||||
ids: mergedIds,
|
||||
);
|
||||
emit(state.copyWith(mergeStatus: Status.success));
|
||||
onSuccess();
|
||||
refresh();
|
||||
} on RateLimitedException catch (e) {
|
||||
onError(e);
|
||||
emit(state.copyWith(mergeStatus: Status.failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,8 +72,12 @@ class PreferenceState extends Equatable {
|
||||
|
||||
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
|
||||
|
||||
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
|
||||
|
||||
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
|
||||
|
||||
bool get devModeEnabled => _isOn<DevMode>();
|
||||
|
||||
double get textScaleFactor =>
|
||||
preferences.singleWhereType<TextScaleFactorPreference>().val;
|
||||
|
||||
|
@ -38,9 +38,19 @@ extension ContextExtension on BuildContext {
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorSnackBar() => showSnackBar(
|
||||
content: Constants.errorMessage,
|
||||
);
|
||||
void showErrorSnackBar([String? message]) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Theme.of(this).colorScheme.errorContainer,
|
||||
content: Text(
|
||||
message ?? Constants.errorMessage,
|
||||
style: TextStyle(
|
||||
color: Theme.of(this).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Rect? get rect {
|
||||
final RenderBox? box = findRenderObject() as RenderBox?;
|
||||
|
@ -27,7 +27,8 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorSnackBar() => context.showErrorSnackBar();
|
||||
void showErrorSnackBar([String? message]) =>
|
||||
context.showErrorSnackBar(message);
|
||||
|
||||
Future<void>? goToItemScreen({
|
||||
required ItemScreenArgs args,
|
||||
|
@ -239,11 +239,12 @@ class HackiApp extends StatelessWidget {
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.appColor != current.appColor ||
|
||||
previous.font != current.font ||
|
||||
previous.textScaleFactor != current.textScaleFactor,
|
||||
previous.textScaleFactor != current.textScaleFactor ||
|
||||
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
|
||||
builder: (BuildContext context, PreferenceState state) {
|
||||
return AdaptiveTheme(
|
||||
key: ValueKey<String>(
|
||||
'''${state.appColor}${state.font}''',
|
||||
'''${state.appColor}${state.font}${state.trueDarkModeEnabled}''',
|
||||
),
|
||||
light: ThemeData(
|
||||
primaryColor: state.appColor,
|
||||
@ -286,6 +287,9 @@ class HackiApp extends StatelessWidget {
|
||||
brightness:
|
||||
isDarkModeEnabled ? Brightness.dark : Brightness.light,
|
||||
seedColor: state.appColor,
|
||||
background: isDarkModeEnabled && state.trueDarkModeEnabled
|
||||
? Palette.black
|
||||
: null,
|
||||
);
|
||||
return FeatureDiscovery(
|
||||
child: MediaQuery(
|
||||
@ -297,16 +301,10 @@ class HackiApp extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: MaterialApp.router(
|
||||
key: Key(state.appColor.hashCode.toString()),
|
||||
title: 'Hacki',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
brightness: isDarkModeEnabled
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
seedColor: state.appColor,
|
||||
),
|
||||
colorScheme: colorScheme,
|
||||
fontFamily: state.font.name,
|
||||
dividerTheme: DividerThemeData(
|
||||
color: Palette.grey.withOpacity(0.2),
|
||||
|
36
lib/models/app_exception.dart
Normal file
36
lib/models/app_exception.dart
Normal file
@ -0,0 +1,36 @@
|
||||
typedef AppExceptionHandler = void Function(AppException);
|
||||
|
||||
class AppException implements Exception {
|
||||
AppException({
|
||||
required this.message,
|
||||
this.stackTrace,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final StackTrace? stackTrace;
|
||||
}
|
||||
|
||||
class RateLimitedException extends AppException {
|
||||
RateLimitedException() : super(message: 'Rate limited...');
|
||||
}
|
||||
|
||||
class RateLimitedWithFallbackException extends AppException {
|
||||
RateLimitedWithFallbackException()
|
||||
: super(message: 'Rate limited, fetching from API instead...');
|
||||
}
|
||||
|
||||
class PossibleParsingException extends AppException {
|
||||
PossibleParsingException({
|
||||
required this.itemId,
|
||||
}) : super(message: 'Possible parsing failure...');
|
||||
|
||||
final int itemId;
|
||||
}
|
||||
|
||||
class BrowserNotRunningException extends AppException {
|
||||
BrowserNotRunningException() : super(message: 'Browser not running...');
|
||||
}
|
||||
|
||||
class GenericException extends AppException {
|
||||
GenericException() : super(message: 'Something went wrong...');
|
||||
}
|
@ -90,8 +90,13 @@ class Item extends Equatable {
|
||||
final List<int> kids;
|
||||
final List<int> parts;
|
||||
|
||||
String get timeAgo =>
|
||||
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString();
|
||||
String get timeAgo {
|
||||
int time = this.time;
|
||||
if (time < 9999999999) {
|
||||
time = time * 1000;
|
||||
}
|
||||
return DateTime.fromMillisecondsSinceEpoch(time).toTimeAgoString();
|
||||
}
|
||||
|
||||
bool get isPoll => type == 'poll';
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
export 'app_exception.dart';
|
||||
export 'comments_order.dart';
|
||||
export 'discoverable_feature.dart';
|
||||
export 'export_destination.dart';
|
||||
|
@ -45,6 +45,8 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
const SwipeGesturePreference(),
|
||||
const HapticFeedbackPreference(),
|
||||
const EyeCandyModePreference(),
|
||||
const TrueDarkModePreference(),
|
||||
const DevMode(),
|
||||
],
|
||||
);
|
||||
|
||||
@ -68,6 +70,7 @@ const bool _notificationModeDefaultValue = true;
|
||||
const bool _swipeGestureModeDefaultValue = false;
|
||||
const bool _displayModeDefaultValue = true;
|
||||
const bool _eyeCandyModeDefaultValue = false;
|
||||
const bool _trueDarkModeDefaultValue = false;
|
||||
const bool _hapticFeedbackModeDefaultValue = true;
|
||||
const bool _readerModeDefaultValue = true;
|
||||
const bool _markReadStoriesModeDefaultValue = true;
|
||||
@ -77,6 +80,7 @@ const bool _collapseModeDefaultValue = true;
|
||||
const bool _autoScrollModeDefaultValue = false;
|
||||
const bool _customTabModeDefaultValue = false;
|
||||
const bool _paginationModeDefaultValue = false;
|
||||
const bool _devModeDefaultValue = false;
|
||||
const double _textScaleFactorDefaultValue = 1;
|
||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||
@ -88,6 +92,27 @@ final int _tabOrderDefaultValue =
|
||||
final int _markStoriesAsReadWhenPreferenceDefaultValue =
|
||||
StoryMarkingMode.tap.index;
|
||||
|
||||
class DevMode extends BooleanPreference {
|
||||
const DevMode({bool? val}) : super(val: val ?? _devModeDefaultValue);
|
||||
|
||||
@override
|
||||
DevMode copyWith({required bool? val}) {
|
||||
return DevMode(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'devMode';
|
||||
|
||||
@override
|
||||
String get title => 'Dev Mode';
|
||||
|
||||
@override
|
||||
String get subtitle => '';
|
||||
|
||||
@override
|
||||
bool get isDisplayable => false;
|
||||
}
|
||||
|
||||
class SwipeGesturePreference extends BooleanPreference {
|
||||
const SwipeGesturePreference({bool? val})
|
||||
: super(val: val ?? _swipeGestureModeDefaultValue);
|
||||
@ -335,6 +360,25 @@ class CustomTabPreference extends BooleanPreference {
|
||||
bool get isDisplayable => Platform.isAndroid;
|
||||
}
|
||||
|
||||
class TrueDarkModePreference extends BooleanPreference {
|
||||
const TrueDarkModePreference({bool? val})
|
||||
: super(val: val ?? _trueDarkModeDefaultValue);
|
||||
|
||||
@override
|
||||
TrueDarkModePreference copyWith({required bool? val}) {
|
||||
return TrueDarkModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'trueDarkMode';
|
||||
|
||||
@override
|
||||
String get title => 'True Dark Mode';
|
||||
|
||||
@override
|
||||
String get subtitle => 'real dark.';
|
||||
}
|
||||
|
||||
class HapticFeedbackPreference extends BooleanPreference {
|
||||
const HapticFeedbackPreference({bool? val})
|
||||
: super(val: val ?? _hapticFeedbackModeDefaultValue);
|
||||
@ -352,9 +396,6 @@ class HapticFeedbackPreference extends BooleanPreference {
|
||||
|
||||
@override
|
||||
String get subtitle => '';
|
||||
|
||||
@override
|
||||
bool get isDisplayable => Platform.isIOS;
|
||||
}
|
||||
|
||||
class FetchModePreference extends IntPreference {
|
||||
|
@ -1,11 +1,33 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:html/dom.dart' hide Comment;
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:html_unescape/html_unescape.dart';
|
||||
|
||||
/// For fetching anything that cannot be fetched through Hacker News API.
|
||||
class HackerNewsWebRepository {
|
||||
HackerNewsWebRepository();
|
||||
HackerNewsWebRepository({Dio? dio}) : _dio = dio ?? Dio();
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const Map<String, String> _headers = <String, String>{
|
||||
'accept':
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
'cache-control': 'max-age=0',
|
||||
'sec-fetch-dest': 'document',
|
||||
'sec-fetch-mode': 'navigate',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'sec-fetch-user': '?1',
|
||||
'upgrade-insecure-requests': '1',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
|
||||
};
|
||||
|
||||
static const String _favoritesBaseUrl =
|
||||
'https://news.ycombinator.com/favorites?id=';
|
||||
@ -15,23 +37,35 @@ class HackerNewsWebRepository {
|
||||
Future<Iterable<int>> fetchFavorites({required String of}) async {
|
||||
final String username = of;
|
||||
final List<int> allIds = <int>[];
|
||||
int page = 0;
|
||||
int page = 1;
|
||||
const int maxPage = 2;
|
||||
|
||||
Future<Iterable<int>> fetchIds(int page) async {
|
||||
final Uri url = Uri.parse('$_favoritesBaseUrl$username&p=$page');
|
||||
final Response response = await get(url);
|
||||
final Document document = parse(response.body);
|
||||
final List<Element> elements = document.querySelectorAll(_aThingSelector);
|
||||
final Iterable<int> parsedIds = elements
|
||||
.map(
|
||||
(Element e) => int.tryParse(e.id),
|
||||
)
|
||||
.whereNotNull();
|
||||
return parsedIds;
|
||||
Future<Iterable<int>> fetchIds(int page, {bool isComment = false}) async {
|
||||
try {
|
||||
final Uri url = Uri.parse(
|
||||
'''$_favoritesBaseUrl$username${isComment ? '&comments=t' : ''}&p=$page''',
|
||||
);
|
||||
final Response<String> response = await _dio.getUri<String>(url);
|
||||
|
||||
/// Due to rate limiting, we have a short break here.
|
||||
await Future<void>.delayed(AppDurations.twoSeconds);
|
||||
|
||||
final Document document = parse(response.data);
|
||||
final List<Element> elements =
|
||||
document.querySelectorAll(_aThingSelector);
|
||||
final Iterable<int> parsedIds =
|
||||
elements.map((Element e) => int.tryParse(e.id)).whereNotNull();
|
||||
return parsedIds;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == HttpStatus.forbidden) {
|
||||
throw RateLimitedException();
|
||||
}
|
||||
throw GenericException();
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<int> ids;
|
||||
while (true) {
|
||||
while (page <= maxPage) {
|
||||
ids = await fetchIds(page);
|
||||
if (ids.isEmpty) {
|
||||
break;
|
||||
@ -40,6 +74,192 @@ class HackerNewsWebRepository {
|
||||
page++;
|
||||
}
|
||||
|
||||
page = 1;
|
||||
while (page <= maxPage) {
|
||||
ids = await fetchIds(page, isComment: true);
|
||||
if (ids.isEmpty) {
|
||||
break;
|
||||
}
|
||||
allIds.addAll(ids);
|
||||
page++;
|
||||
}
|
||||
|
||||
return allIds;
|
||||
}
|
||||
|
||||
static const String _itemBaseUrl = 'https://news.ycombinator.com/item?id=';
|
||||
static const String _athingComtrSelector =
|
||||
'#hnmain > tbody > tr:nth-child(3) > td > table > tbody > .athing.comtr';
|
||||
static const String _commentTextSelector =
|
||||
'''td > table > tbody > tr > td.default > div.comment''';
|
||||
static const String _commentHeadSelector =
|
||||
'''td > table > tbody > tr > td.default > div > span > a''';
|
||||
static const String _commentAgeSelector =
|
||||
'''td > table > tbody > tr > td.default > div > span > span.age''';
|
||||
static const String _commentIndentSelector =
|
||||
'''td > table > tbody > tr > td.ind''';
|
||||
|
||||
Stream<Comment> fetchCommentsStream(Item item) async* {
|
||||
final int itemId = item.id;
|
||||
final int? descendants = item is Story ? item.descendants : null;
|
||||
int parentTextCount = 0;
|
||||
|
||||
Future<Iterable<Element>> fetchElements(int page) async {
|
||||
try {
|
||||
final Uri url = Uri.parse('$_itemBaseUrl$itemId&p=$page');
|
||||
final Options option = Options(
|
||||
headers: _headers,
|
||||
persistentConnection: true,
|
||||
);
|
||||
final Response<String> response = await _dio.getUri<String>(
|
||||
url,
|
||||
options: option,
|
||||
);
|
||||
final String data = response.data ?? '';
|
||||
|
||||
if (page == 1) {
|
||||
parentTextCount = 'parent'.allMatches(data).length;
|
||||
}
|
||||
|
||||
final Document document = parse(data);
|
||||
final List<Element> elements =
|
||||
document.querySelectorAll(_athingComtrSelector);
|
||||
return elements;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == HttpStatus.forbidden) {
|
||||
throw RateLimitedWithFallbackException();
|
||||
}
|
||||
throw GenericException();
|
||||
}
|
||||
}
|
||||
|
||||
if (descendants == 0 || item.kids.isEmpty) return;
|
||||
|
||||
final Set<int> fetchedCommentIds = <int>{};
|
||||
int page = 1;
|
||||
Iterable<Element> elements = await fetchElements(page);
|
||||
final Map<int, int> indentToParentId = <int, int>{};
|
||||
|
||||
while (elements.isNotEmpty) {
|
||||
for (final Element element in elements) {
|
||||
/// Get comment id.
|
||||
final String cmtIdString = element.attributes['id'] ?? '';
|
||||
final int? cmtId = int.tryParse(cmtIdString);
|
||||
|
||||
/// Get comment text.
|
||||
final Element? cmtTextElement =
|
||||
element.querySelector(_commentTextSelector);
|
||||
final String parsedText = await compute(
|
||||
_parseCommentTextHtml,
|
||||
cmtTextElement?.innerHtml ?? '',
|
||||
);
|
||||
|
||||
/// Get comment author.
|
||||
final Element? cmtHeadElement =
|
||||
element.querySelector(_commentHeadSelector);
|
||||
final String? cmtAuthor = cmtHeadElement?.text;
|
||||
|
||||
/// Get comment age.
|
||||
final Element? cmtAgeElement =
|
||||
element.querySelector(_commentAgeSelector);
|
||||
final String? ageString = cmtAgeElement?.attributes['title'];
|
||||
|
||||
final int? timestamp = ageString == null
|
||||
? null
|
||||
: DateTime.parse(ageString)
|
||||
.copyWith(isUtc: true)
|
||||
.millisecondsSinceEpoch;
|
||||
|
||||
/// Get comment indent.
|
||||
final Element? cmtIndentElement =
|
||||
element.querySelector(_commentIndentSelector);
|
||||
final String? indentString = cmtIndentElement?.attributes['indent'];
|
||||
final int indent =
|
||||
indentString == null ? 0 : (int.tryParse(indentString) ?? 0);
|
||||
|
||||
indentToParentId[indent] = cmtId ?? 0;
|
||||
final int parentId = indentToParentId[indent - 1] ?? -1;
|
||||
|
||||
final Comment cmt = Comment(
|
||||
id: cmtId ?? 0,
|
||||
time: timestamp ?? 0,
|
||||
parent: parentId,
|
||||
score: 0,
|
||||
by: cmtAuthor ?? '',
|
||||
text: parsedText,
|
||||
kids: const <int>[],
|
||||
dead: false,
|
||||
deleted: false,
|
||||
hidden: false,
|
||||
level: indent,
|
||||
isFromCache: false,
|
||||
);
|
||||
|
||||
/// Skip any comment with no valid id or timestamp.
|
||||
if (cmt.id == 0 || timestamp == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/// Duplicate comment means we are done fetching all the comments.
|
||||
if (fetchedCommentIds.contains(cmt.id)) return;
|
||||
|
||||
fetchedCommentIds.add(cmt.id);
|
||||
yield cmt;
|
||||
}
|
||||
|
||||
/// If we didn't successfully got any comment on first page,
|
||||
/// and we are sure there are comments there based on the count of
|
||||
/// 'parent' text, then this might be a parsing error and possibly is
|
||||
/// caused by HN changing their HTML structure, therefore here we
|
||||
/// throw an error so that we can fallback to use API instead.
|
||||
if (page == 1 && parentTextCount > 0 && fetchedCommentIds.isEmpty) {
|
||||
throw PossibleParsingException(itemId: itemId);
|
||||
}
|
||||
|
||||
if (descendants != null && fetchedCommentIds.length >= descendants) {
|
||||
return;
|
||||
}
|
||||
|
||||
/// Due to rate limiting, we have a short break here.
|
||||
await Future<void>.delayed(AppDurations.twoSeconds);
|
||||
|
||||
page++;
|
||||
elements = await fetchElements(page);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> _parseCommentTextHtml(String text) async {
|
||||
return HtmlUnescape()
|
||||
.convert(text)
|
||||
.replaceAllMapped(
|
||||
RegExp(
|
||||
r'\<div class="reply"\>(.*?)\<\/div\>',
|
||||
dotAll: true,
|
||||
),
|
||||
(Match match) => '',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(
|
||||
r'\<span class="(.*?)"\>(.*?)\<\/span\>',
|
||||
dotAll: true,
|
||||
),
|
||||
(Match match) => '${match[2]}',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(
|
||||
r'\<p\>(.*?)\<\/p\>',
|
||||
dotAll: true,
|
||||
),
|
||||
(Match match) => '\n\n${match[1]}',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(r'\<a href=\"(.*?)\".*?\>.*?\<\/a\>'),
|
||||
(Match match) => match[1] ?? '',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(r'\<i\>(.*?)\<\/i\>'),
|
||||
(Match match) => '*${match[1]}*',
|
||||
)
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sembast/sembast.dart';
|
||||
@ -12,23 +14,34 @@ import 'package:sembast/sembast_io.dart';
|
||||
/// documents directory assigned by host system which you can retrieve
|
||||
/// by calling [getApplicationDocumentsDirectory].
|
||||
class SembastRepository {
|
||||
SembastRepository({Database? database}) {
|
||||
SembastRepository({
|
||||
Database? database,
|
||||
Database? cache,
|
||||
}) {
|
||||
if (database == null) {
|
||||
initializeDatabase();
|
||||
} else {
|
||||
_database = database;
|
||||
}
|
||||
|
||||
if (cache == null) {
|
||||
initializeCache();
|
||||
} else {
|
||||
_cache = cache;
|
||||
}
|
||||
}
|
||||
|
||||
Database? _database;
|
||||
Database? _cache;
|
||||
List<int>? _idsOfCommentsRepliedToMe;
|
||||
|
||||
static const String _cachedCommentsKey = 'cachedComments';
|
||||
static const String _commentsKey = 'comments';
|
||||
static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe';
|
||||
static const String _metadataCacheKey = 'metadata';
|
||||
|
||||
Future<Database> initializeDatabase() async {
|
||||
final Directory dir = await getApplicationDocumentsDirectory();
|
||||
final Directory dir = await getApplicationCacheDirectory();
|
||||
await dir.create(recursive: true);
|
||||
final String dbPath = join(dir.path, 'hacki.db');
|
||||
final DatabaseFactory dbFactory = databaseFactoryIo;
|
||||
@ -37,6 +50,16 @@ class SembastRepository {
|
||||
return db;
|
||||
}
|
||||
|
||||
Future<Database> initializeCache() async {
|
||||
final Directory dir = await getTemporaryDirectory();
|
||||
await dir.create(recursive: true);
|
||||
final String dbPath = join(dir.path, 'hacki_cache.db');
|
||||
final DatabaseFactory dbFactory = databaseFactoryIo;
|
||||
final Database db = await dbFactory.openDatabase(dbPath);
|
||||
_cache = db;
|
||||
return db;
|
||||
}
|
||||
|
||||
//#region Cached comments for time machine feature.
|
||||
Future<Map<String, Object?>> cacheComment(Comment comment) async {
|
||||
final Database db = _database ?? await initializeDatabase();
|
||||
@ -177,10 +200,50 @@ class SembastRepository {
|
||||
|
||||
//#endregion
|
||||
|
||||
Future<FileSystemEntity> deleteAll() async {
|
||||
//#region
|
||||
|
||||
Future<void> cacheMetadata({
|
||||
required String key,
|
||||
required WebInfo info,
|
||||
}) async {
|
||||
final Database db = _cache ?? await initializeCache();
|
||||
final StoreRef<String, Map<String, Object?>> store =
|
||||
stringMapStoreFactory.store(_metadataCacheKey);
|
||||
|
||||
return db.transaction((Transaction txn) async {
|
||||
await store.record(key).put(txn, info.toJson());
|
||||
});
|
||||
}
|
||||
|
||||
Future<WebInfo?> getCachedMetadata({
|
||||
required String key,
|
||||
}) async {
|
||||
final Database db = _cache ?? await initializeCache();
|
||||
final StoreRef<String, Map<String, Object?>> store =
|
||||
stringMapStoreFactory.store(_metadataCacheKey);
|
||||
final RecordSnapshot<String, Map<String, Object?>>? snapshot =
|
||||
await store.record(key).getSnapshot(db);
|
||||
if (snapshot != null) {
|
||||
final WebInfo info = WebInfo.fromJson(snapshot.value);
|
||||
return info;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
Future<FileSystemEntity> deleteCachedComments() async {
|
||||
final Directory dir = await getApplicationDocumentsDirectory();
|
||||
await dir.create(recursive: true);
|
||||
final String dbPath = join(dir.path, 'hacki.db');
|
||||
return File(dbPath).delete();
|
||||
}
|
||||
|
||||
Future<FileSystemEntity> deleteCachedMetadata() async {
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
await tempDir.create(recursive: true);
|
||||
final String cachePath = join(tempDir.path, 'hacki_cache.db');
|
||||
return File(cachePath).delete();
|
||||
}
|
||||
}
|
||||
|
@ -69,6 +69,7 @@ class ItemScreen extends StatefulWidget {
|
||||
BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => CommentsCubit(
|
||||
filterCubit: context.read<FilterCubit>(),
|
||||
preferenceCubit: context.read<PreferenceCubit>(),
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
item: args.item,
|
||||
@ -79,6 +80,8 @@ class ItemScreen extends StatefulWidget {
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetAncestors: args.targetComments,
|
||||
useCommentCache: args.useCommentCache,
|
||||
onError: (AppException e) =>
|
||||
context.showErrorSnackBar(e.message),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -110,6 +113,7 @@ class ItemScreen extends StatefulWidget {
|
||||
BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => CommentsCubit(
|
||||
filterCubit: context.read<FilterCubit>(),
|
||||
preferenceCubit: context.read<PreferenceCubit>(),
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
item: args.item,
|
||||
@ -121,6 +125,8 @@ class ItemScreen extends StatefulWidget {
|
||||
)..init(
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetAncestors: args.targetComments,
|
||||
onError: (AppException e) =>
|
||||
context.showErrorSnackBar(e.message),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -2,6 +2,7 @@ import 'package:animations/animations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/comments/comments_cubit.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
@ -65,9 +66,11 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
|
||||
super.initState();
|
||||
scrollController.addListener(onScroll);
|
||||
textEditingController.text = widget.commentsCubit.state.inThreadSearchQuery;
|
||||
if (textEditingController.text.isEmpty) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
Future<void>.delayed(AppDurations.ms300, () {
|
||||
if (textEditingController.text.isEmpty) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -59,7 +59,12 @@ class MainView extends StatelessWidget {
|
||||
if (context.read<StoriesBloc>().state.isOfflineReading ==
|
||||
false &&
|
||||
state.onlyShowTargetComment == false) {
|
||||
unawaited(context.read<CommentsCubit>().refresh());
|
||||
unawaited(
|
||||
context.read<CommentsCubit>().refresh(
|
||||
onError: (AppException e) =>
|
||||
context.showErrorSnackBar(e.message),
|
||||
),
|
||||
);
|
||||
|
||||
if (state.item.isPoll) {
|
||||
context.read<PollCubit>().refresh();
|
||||
@ -145,27 +150,28 @@ class MainView extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
height: Dimens.pt4,
|
||||
bottom: Dimens.zero,
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||
buildWhen: (CommentsState prev, CommentsState current) =>
|
||||
prev.status != current.status,
|
||||
builder: (BuildContext context, CommentsState state) {
|
||||
return AnimatedOpacity(
|
||||
opacity: state.status == CommentsStatus.inProgress
|
||||
? NumSwitch.on
|
||||
: NumSwitch.off,
|
||||
duration: const Duration(
|
||||
milliseconds: _loadingIndicatorOpacityAnimationDuration,
|
||||
),
|
||||
child: const LinearProgressIndicator(),
|
||||
);
|
||||
},
|
||||
if (context.read<PreferenceCubit>().state.devModeEnabled)
|
||||
Positioned(
|
||||
height: Dimens.pt4,
|
||||
bottom: Dimens.zero,
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||
buildWhen: (CommentsState prev, CommentsState current) =>
|
||||
prev.status != current.status,
|
||||
builder: (BuildContext context, CommentsState state) {
|
||||
return AnimatedOpacity(
|
||||
opacity: state.status == CommentsStatus.inProgress
|
||||
? NumSwitch.on
|
||||
: NumSwitch.off,
|
||||
duration: const Duration(
|
||||
milliseconds: _loadingIndicatorOpacityAnimationDuration,
|
||||
),
|
||||
child: const LinearProgressIndicator(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -250,8 +256,8 @@ class _ParentItemSection extends StatelessWidget {
|
||||
const Spacer(),
|
||||
Text(
|
||||
item.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).metadataColor,
|
||||
),
|
||||
textScaler: MediaQuery.of(context).textScaler,
|
||||
),
|
||||
|
@ -147,22 +147,28 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
builder: (
|
||||
BuildContext context,
|
||||
Status status,
|
||||
) =>
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<FavCubit>().merge();
|
||||
},
|
||||
child: status == Status.inProgress
|
||||
? const SizedBox(
|
||||
height: Dimens.pt12,
|
||||
width: Dimens.pt12,
|
||||
child:
|
||||
CustomCircularProgressIndicator(
|
||||
strokeWidth: Dimens.pt2,
|
||||
),
|
||||
)
|
||||
: const Text('Sync from Hacker News'),
|
||||
),
|
||||
) {
|
||||
return TextButton(
|
||||
onPressed: () =>
|
||||
context.read<FavCubit>().merge(
|
||||
onError: (AppException e) =>
|
||||
showErrorSnackBar(e.message),
|
||||
onSuccess: () => showSnackBar(
|
||||
content: '''Sync completed.''',
|
||||
),
|
||||
),
|
||||
child: status == Status.inProgress
|
||||
? const SizedBox(
|
||||
height: Dimens.pt12,
|
||||
width: Dimens.pt12,
|
||||
child:
|
||||
CustomCircularProgressIndicator(
|
||||
strokeWidth: Dimens.pt2,
|
||||
),
|
||||
)
|
||||
: const Text('Sync from Hacker News'),
|
||||
);
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
|
@ -89,8 +89,8 @@ class InboxView extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'''${e.timeAgo} from ${e.by}:''',
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).metadataColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
|
@ -301,6 +301,17 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('nothing interesting here.'),
|
||||
onTap: showAboutHackiDialog,
|
||||
onLongPress: () {
|
||||
final DevMode updatedDevMode =
|
||||
DevMode(val: !preferenceState.devModeEnabled);
|
||||
context.read<PreferenceCubit>().update(updatedDevMode);
|
||||
HapticFeedbackUtil.heavy();
|
||||
if (updatedDevMode.val) {
|
||||
showSnackBar(content: 'You are a dev now.');
|
||||
} else {
|
||||
showSnackBar(content: 'Dev mode disabled');
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt48,
|
||||
@ -498,6 +509,12 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
.whenComplete(
|
||||
DefaultCacheManager().emptyCache,
|
||||
)
|
||||
.whenComplete(
|
||||
locator.get<SembastRepository>().deleteCachedComments,
|
||||
)
|
||||
.whenComplete(
|
||||
locator.get<SembastRepository>().deleteCachedMetadata,
|
||||
)
|
||||
.whenComplete(() {
|
||||
showSnackBar(content: 'Cache cleared!');
|
||||
});
|
||||
@ -645,6 +662,9 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
actionsPadding: const EdgeInsets.all(
|
||||
Dimens.pt16,
|
||||
),
|
||||
actions: <Widget>[
|
||||
ElevatedButton(
|
||||
onPressed: onSendEmailTapped,
|
||||
|
@ -188,20 +188,21 @@ class CommentTile extends StatelessWidget {
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
if (!comment.dead && isNew)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4),
|
||||
child: Icon(
|
||||
Icons.sunny_snowing,
|
||||
size: 16,
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
// Commented out for now, maybe review later.
|
||||
// if (!comment.dead && isNew)
|
||||
// const Padding(
|
||||
// padding: EdgeInsets.only(left: 4),
|
||||
// child: Icon(
|
||||
// Icons.sunny_snowing,
|
||||
// size: 16,
|
||||
// color: Palette.grey,
|
||||
// ),
|
||||
// ),
|
||||
const Spacer(),
|
||||
Text(
|
||||
comment.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).metadataColor,
|
||||
),
|
||||
textScaler: MediaQuery.of(context).textScaler,
|
||||
),
|
||||
|
@ -97,8 +97,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
showAuthor
|
||||
? '''${e.timeAgo} by ${e.by}'''
|
||||
: e.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).metadataColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
@ -147,6 +147,11 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
if (useSimpleTileForStory || !showWebPreviewOnStoryTile)
|
||||
const Divider(
|
||||
height: Dimens.zero,
|
||||
)
|
||||
else if (context.read<SplitViewCubit>().state.enabled)
|
||||
const Divider(
|
||||
height: Dimens.pt6,
|
||||
color: Palette.transparent,
|
||||
),
|
||||
];
|
||||
} else if (e is Comment) {
|
||||
@ -186,8 +191,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
showAuthor
|
||||
? '''${e.timeAgo} by ${e.by}'''
|
||||
: e.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).metadataColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
|
@ -119,10 +119,15 @@ class LinkView extends StatelessWidget {
|
||||
: CachedNetworkImage(
|
||||
imageUrl: imageUri!,
|
||||
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
|
||||
memCacheHeight: layoutHeight.toInt() * 4,
|
||||
cacheKey: imageUri,
|
||||
errorWidget: (_, __, ___) =>
|
||||
const SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Text(
|
||||
r'¯\_(ツ)_/¯',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -17,7 +17,6 @@ class _OnboardingViewState extends State<OnboardingView> {
|
||||
final Throttle throttle = Throttle(delay: _throttleDelay);
|
||||
|
||||
static const Duration _throttleDelay = AppDurations.ms100;
|
||||
static const double _screenshotHeight = 600;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -44,7 +43,7 @@ class _OnboardingViewState extends State<OnboardingView> {
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: SizedBox(
|
||||
height: _screenshotHeight,
|
||||
height: MediaQuery.of(context).size.height * 0.8,
|
||||
child: PageView(
|
||||
controller: pageController,
|
||||
scrollDirection: Axis.vertical,
|
||||
@ -69,7 +68,7 @@ class _OnboardingViewState extends State<OnboardingView> {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: Dimens.pt40,
|
||||
bottom: MediaQuery.of(context).viewPadding.bottom,
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: ElevatedButton(
|
||||
@ -115,16 +114,14 @@ class _PageViewChild extends StatelessWidget {
|
||||
final String path;
|
||||
final String description;
|
||||
|
||||
static const double _height = 500;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 8,
|
||||
elevation: Dimens.pt8,
|
||||
child: SizedBox(
|
||||
height: _height,
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: Image.asset(path),
|
||||
),
|
||||
),
|
||||
|
@ -14,9 +14,9 @@ import 'package:html/dom.dart' hide Comment, Text;
|
||||
import 'package:html/parser.dart' as parser;
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
abstract class InfoBase {
|
||||
late DateTime _timeout;
|
||||
late bool _shouldRetry;
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
@ -97,13 +97,10 @@ class WebAnalyzer {
|
||||
/// Get web information
|
||||
/// return [InfoBase]
|
||||
static InfoBase? getInfoFromCache(String? cacheKey) {
|
||||
if (cacheKey == null) return null;
|
||||
|
||||
final InfoBase? info = cacheMap[cacheKey];
|
||||
|
||||
if (info != null) {
|
||||
if (!info._timeout.isAfter(DateTime.now())) {
|
||||
cacheMap.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
@ -118,23 +115,31 @@ class WebAnalyzer {
|
||||
final String key = getKey(story);
|
||||
final String url = story.url;
|
||||
|
||||
/// [1] Try to fetch from mem cache.
|
||||
InfoBase? info = getInfoFromCache(key);
|
||||
|
||||
if (info != null) return info;
|
||||
if (info != null) {
|
||||
locator.get<Logger>().d('''
|
||||
Fetched mem cached metadata using key $key for $story:
|
||||
${info.toJson()}
|
||||
''');
|
||||
return info;
|
||||
}
|
||||
|
||||
/// [2] If story doesn't have a url and text is not empty,
|
||||
/// just use story title and text.
|
||||
if (story.url.isEmpty && story.text.isNotEmpty) {
|
||||
info = WebInfo(
|
||||
title: story.title,
|
||||
description: story.text,
|
||||
)
|
||||
.._timeout = DateTime.now().add(cache)
|
||||
.._shouldRetry = false;
|
||||
).._shouldRetry = false;
|
||||
|
||||
cacheMap[key] = info;
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/// [3] If in offline mode, use comment text for description.
|
||||
if (offlineReading) {
|
||||
int index = 0;
|
||||
Comment? comment;
|
||||
@ -149,9 +154,7 @@ class WebAnalyzer {
|
||||
info = WebInfo(
|
||||
title: story.title,
|
||||
description: comment != null ? '${comment.by}: ${comment.text}' : null,
|
||||
)
|
||||
.._shouldRetry = false
|
||||
.._timeout = DateTime.now();
|
||||
).._shouldRetry = false;
|
||||
|
||||
cacheMap[key] = info;
|
||||
|
||||
@ -159,15 +162,41 @@ class WebAnalyzer {
|
||||
}
|
||||
|
||||
try {
|
||||
/// [4] Try to fetch from file cache.
|
||||
info = await locator.get<SembastRepository>().getCachedMetadata(key: key);
|
||||
|
||||
/// [5] If there is file cache, move it to mem cache for later retrieval.
|
||||
if (info != null) {
|
||||
locator.get<Logger>().d('''
|
||||
Fetched file cached metadata using key $key for $story:
|
||||
${info.toJson()}
|
||||
''');
|
||||
cacheMap[key] = info;
|
||||
return info;
|
||||
}
|
||||
|
||||
/// [6] Try to analyze the web for metadata.
|
||||
info = await _getInfoByIsolate(
|
||||
url: url,
|
||||
multimedia: multimedia,
|
||||
story: story,
|
||||
);
|
||||
|
||||
/// [7] If web analyzing was successful, cache it in both mem and file.
|
||||
if (info != null && !info._shouldRetry) {
|
||||
info._timeout = DateTime.now().add(cache);
|
||||
cacheMap[key] = info;
|
||||
|
||||
if (info is WebInfo) {
|
||||
locator
|
||||
.get<Logger>()
|
||||
.d('Caching metadata using key $key for $story.');
|
||||
unawaited(
|
||||
locator.get<SembastRepository>().cacheMetadata(
|
||||
key: key,
|
||||
info: info,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
@ -175,9 +204,7 @@ class WebAnalyzer {
|
||||
return WebInfo(
|
||||
title: story.title,
|
||||
description: story.text,
|
||||
)
|
||||
.._shouldRetry = true
|
||||
.._timeout = DateTime.now();
|
||||
).._shouldRetry = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -393,10 +420,9 @@ class WebAnalyzer {
|
||||
try {
|
||||
html = gbk.decode(response.bodyBytes);
|
||||
} catch (e) {
|
||||
// locator.get<Logger>().log(
|
||||
// Level.error,
|
||||
// 'Web page resolution failure from:$url Error:$e',
|
||||
// );
|
||||
locator
|
||||
.get<Logger>()
|
||||
.e('''Web page resolution failure from:$url Error:$e''');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension ThemeDataExtension on ThemeData {
|
||||
Color get readGrey => colorScheme.onSurface.withOpacity(0.4);
|
||||
Color get readGrey => colorScheme.onSurface.withOpacity(0.6);
|
||||
|
||||
Color get metadataColor => colorScheme.onSurface.withOpacity(0.6);
|
||||
Color get metadataColor => colorScheme.onSurface.withOpacity(0.8);
|
||||
}
|
||||
|
@ -14,4 +14,10 @@ abstract class HapticFeedbackUtil {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
static void heavy() {
|
||||
if (enabled) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1446,4 +1446,4 @@ packages:
|
||||
version: "3.1.2"
|
||||
sdks:
|
||||
dart: ">=3.2.0-194.0.dev <4.0.0"
|
||||
flutter: ">=3.16.2"
|
||||
flutter: ">=3.16.3"
|
||||
|
@ -1,11 +1,11 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 2.5.0+134
|
||||
version: 2.6.0+135
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
flutter: "3.16.2"
|
||||
flutter: "3.16.3"
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^3.2.0
|
||||
|
Submodule submodules/flutter updated: 9e1c857886...b0366e0a3f
Reference in New Issue
Block a user