Compare commits

...

15 Commits

31 changed files with 728 additions and 162 deletions

View File

@ -0,0 +1,3 @@
- Return of true dark mode.
- Better comment fetching strategy.
- Minor UI fixes.

View File

@ -114,6 +114,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.logout(); await _authRepository.logout();
await _preferenceRepository.updateUnreadCommentsIds(<int>[]); await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
await _sembastRepository.deleteAll(); await _sembastRepository.deleteCachedComments();
} }
} }

View File

@ -35,7 +35,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
super(const StoriesState.init()) { super(const StoriesState.init()) {
on<LoadStories>( on<LoadStories>(
onLoadStories, onLoadStories,
transformer: sequential(), transformer: concurrent(),
); );
on<StoriesInitialize>(onInitialize); on<StoriesInitialize>(onInitialize);
on<StoriesRefresh>(onRefresh); on<StoriesRefresh>(onRefresh);

View File

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
@ -25,6 +26,7 @@ part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> { class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({ CommentsCubit({
required FilterCubit filterCubit, required FilterCubit filterCubit,
required PreferenceCubit preferenceCubit,
required CollapseCache collapseCache, required CollapseCache collapseCache,
required bool isOfflineReading, required bool isOfflineReading,
required Item item, required Item item,
@ -34,8 +36,10 @@ class CommentsCubit extends Cubit<CommentsState> {
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
HackerNewsRepository? hackerNewsRepository, HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger, Logger? logger,
}) : _filterCubit = filterCubit, }) : _filterCubit = filterCubit,
_preferenceCubit = preferenceCubit,
_collapseCache = collapseCache, _collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(), _commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository = _offlineRepository =
@ -44,6 +48,8 @@ class CommentsCubit extends Cubit<CommentsState> {
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
_hackerNewsRepository = _hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(), _logger = logger ?? locator.get<Logger>(),
super( super(
CommentsState.init( CommentsState.init(
@ -55,11 +61,13 @@ class CommentsCubit extends Cubit<CommentsState> {
); );
final FilterCubit _filterCubit; final FilterCubit _filterCubit;
final PreferenceCubit _preferenceCubit;
final CollapseCache _collapseCache; final CollapseCache _collapseCache;
final CommentCache _commentCache; final CommentCache _commentCache;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
final HackerNewsRepository _hackerNewsRepository; final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger; final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController(); final ItemScrollController itemScrollController = ItemScrollController();
@ -75,6 +83,11 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions = final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{}; <int, StreamSubscription<Comment>>{};
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
}
@override @override
void emit(CommentsState state) { void emit(CommentsState state) {
if (!isClosed) { if (!isClosed) {
@ -86,6 +99,8 @@ class CommentsCubit extends Cubit<CommentsState> {
bool onlyShowTargetComment = false, bool onlyShowTargetComment = false,
bool useCommentCache = false, bool useCommentCache = false,
List<Comment>? targetAncestors, List<Comment>? targetAncestors,
AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async { }) async {
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) { if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
emit( emit(
@ -143,11 +158,47 @@ class CommentsCubit extends Cubit<CommentsState> {
getFromCache: useCommentCache ? _commentCache.getComment : null, getFromCache: useCommentCache ? _commentCache.getComment : null,
); );
case FetchMode.eager: case FetchMode.eager:
commentStream = switch (state.order) {
_hackerNewsRepository.fetchAllCommentsRecursivelyStream( case CommentsOrder.natural:
ids: kids, final bool isOnWifi = await _isOnWifi;
getFromCache: useCommentCache ? _commentCache.getComment : null, 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); ..onDone(_onDone);
} }
Future<void> refresh() async { Future<void> refresh({
required AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async {
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.inProgress, status: CommentsStatus.inProgress,
@ -195,14 +249,45 @@ class CommentsCubit extends Cubit<CommentsState> {
final List<int> kids = _sortKids(updatedItem.kids); final List<int> kids = _sortKids(updatedItem.kids);
late final Stream<Comment> commentStream; late final Stream<Comment> commentStream;
if (state.fetchMode == FetchMode.lazy) {
commentStream = _hackerNewsRepository.fetchCommentsStream( switch (state.fetchMode) {
ids: kids, case FetchMode.lazy:
); commentStream = _hackerNewsRepository.fetchCommentsStream(ids: kids);
} else { case FetchMode.eager:
commentStream = _hackerNewsRepository.fetchAllCommentsRecursivelyStream( switch (state.order) {
ids: kids, 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 _streamSubscription = commentStream

View File

@ -2,11 +2,13 @@ import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'fav_state.dart'; part 'fav_state.dart';
@ -17,6 +19,7 @@ class FavCubit extends Cubit<FavState> {
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
HackerNewsRepository? hackerNewsRepository, HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository, HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(), _authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository = _preferenceRepository =
@ -25,6 +28,7 @@ class FavCubit extends Cubit<FavState> {
hackerNewsRepository ?? locator.get<HackerNewsRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository = _hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(), hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(FavState.init()) { super(FavState.init()) {
init(); init();
} }
@ -34,6 +38,7 @@ class FavCubit extends Cubit<FavState> {
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final HackerNewsRepository _hackerNewsRepository; final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository; final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
late final StreamSubscription<String>? _usernameSubscription; late final StreamSubscription<String>? _usernameSubscription;
static const int _pageSize = 20; static const int _pageSize = 20;
@ -93,7 +98,9 @@ class FavCubit extends Cubit<FavState> {
} }
void removeFav(int id) { void removeFav(int id) {
_preferenceRepository.removeFav(username: username, id: id); _preferenceRepository
..removeFav(username: username, id: id)
..removeFav(username: '', id: id);
emit( emit(
state.copyWith( state.copyWith(
@ -167,20 +174,31 @@ class FavCubit extends Cubit<FavState> {
emit(FavState.init()); emit(FavState.init());
} }
Future<void> merge() async { Future<void> merge({
required AppExceptionHandler onError,
required VoidCallback onSuccess,
}) async {
if (_authBloc.state.isLoggedIn) { if (_authBloc.state.isLoggedIn) {
emit(state.copyWith(mergeStatus: Status.inProgress)); emit(state.copyWith(mergeStatus: Status.inProgress));
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites( try {
of: _authBloc.state.username, 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); _logger.d('fetched ${ids.length} favorite items from HN.');
await _preferenceRepository.overwriteFav( final List<int> combinedIds = <int>[...ids, ...state.favIds];
username: username, final LinkedHashSet<int> mergedIds =
ids: mergedIds, LinkedHashSet<int>.from(combinedIds);
); await _preferenceRepository.overwriteFav(
emit(state.copyWith(mergeStatus: Status.success)); username: username,
refresh(); ids: mergedIds,
);
emit(state.copyWith(mergeStatus: Status.success));
onSuccess();
refresh();
} on RateLimitedException catch (e) {
onError(e);
emit(state.copyWith(mergeStatus: Status.failure));
}
} }
} }

View File

@ -72,8 +72,12 @@ class PreferenceState extends Equatable {
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>(); bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>(); bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
bool get devModeEnabled => _isOn<DevMode>();
double get textScaleFactor => double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val; preferences.singleWhereType<TextScaleFactorPreference>().val;

View File

@ -38,9 +38,19 @@ extension ContextExtension on BuildContext {
); );
} }
void showErrorSnackBar() => showSnackBar( void showErrorSnackBar([String? message]) {
content: Constants.errorMessage, 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 { Rect? get rect {
final RenderBox? box = findRenderObject() as RenderBox?; final RenderBox? box = findRenderObject() as RenderBox?;

View File

@ -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({ Future<void>? goToItemScreen({
required ItemScreenArgs args, required ItemScreenArgs args,

View File

@ -239,11 +239,12 @@ class HackiApp extends StatelessWidget {
buildWhen: (PreferenceState previous, PreferenceState current) => buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.appColor != current.appColor || previous.appColor != current.appColor ||
previous.font != current.font || previous.font != current.font ||
previous.textScaleFactor != current.textScaleFactor, previous.textScaleFactor != current.textScaleFactor ||
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
builder: (BuildContext context, PreferenceState state) { builder: (BuildContext context, PreferenceState state) {
return AdaptiveTheme( return AdaptiveTheme(
key: ValueKey<String>( key: ValueKey<String>(
'''${state.appColor}${state.font}''', '''${state.appColor}${state.font}${state.trueDarkModeEnabled}''',
), ),
light: ThemeData( light: ThemeData(
primaryColor: state.appColor, primaryColor: state.appColor,
@ -286,6 +287,9 @@ class HackiApp extends StatelessWidget {
brightness: brightness:
isDarkModeEnabled ? Brightness.dark : Brightness.light, isDarkModeEnabled ? Brightness.dark : Brightness.light,
seedColor: state.appColor, seedColor: state.appColor,
background: isDarkModeEnabled && state.trueDarkModeEnabled
? Palette.black
: null,
); );
return FeatureDiscovery( return FeatureDiscovery(
child: MediaQuery( child: MediaQuery(
@ -297,16 +301,10 @@ class HackiApp extends StatelessWidget {
), ),
), ),
child: MaterialApp.router( child: MaterialApp.router(
key: Key(state.appColor.hashCode.toString()),
title: 'Hacki', title: 'Hacki',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: colorScheme,
brightness: isDarkModeEnabled
? Brightness.dark
: Brightness.light,
seedColor: state.appColor,
),
fontFamily: state.font.name, fontFamily: state.font.name,
dividerTheme: DividerThemeData( dividerTheme: DividerThemeData(
color: Palette.grey.withOpacity(0.2), color: Palette.grey.withOpacity(0.2),

View 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...');
}

View File

@ -90,8 +90,13 @@ class Item extends Equatable {
final List<int> kids; final List<int> kids;
final List<int> parts; final List<int> parts;
String get timeAgo => String get timeAgo {
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString(); int time = this.time;
if (time < 9999999999) {
time = time * 1000;
}
return DateTime.fromMillisecondsSinceEpoch(time).toTimeAgoString();
}
bool get isPoll => type == 'poll'; bool get isPoll => type == 'poll';

View File

@ -1,3 +1,4 @@
export 'app_exception.dart';
export 'comments_order.dart'; export 'comments_order.dart';
export 'discoverable_feature.dart'; export 'discoverable_feature.dart';
export 'export_destination.dart'; export 'export_destination.dart';

View File

@ -45,6 +45,8 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const SwipeGesturePreference(), const SwipeGesturePreference(),
const HapticFeedbackPreference(), const HapticFeedbackPreference(),
const EyeCandyModePreference(), const EyeCandyModePreference(),
const TrueDarkModePreference(),
const DevMode(),
], ],
); );
@ -68,6 +70,7 @@ const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false; const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true; const bool _displayModeDefaultValue = true;
const bool _eyeCandyModeDefaultValue = false; const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _hapticFeedbackModeDefaultValue = true; const bool _hapticFeedbackModeDefaultValue = true;
const bool _readerModeDefaultValue = true; const bool _readerModeDefaultValue = true;
const bool _markReadStoriesModeDefaultValue = true; const bool _markReadStoriesModeDefaultValue = true;
@ -77,6 +80,7 @@ const bool _collapseModeDefaultValue = true;
const bool _autoScrollModeDefaultValue = false; const bool _autoScrollModeDefaultValue = false;
const bool _customTabModeDefaultValue = false; const bool _customTabModeDefaultValue = false;
const bool _paginationModeDefaultValue = false; const bool _paginationModeDefaultValue = false;
const bool _devModeDefaultValue = false;
const double _textScaleFactorDefaultValue = 1; const double _textScaleFactorDefaultValue = 1;
final int _fetchModeDefaultValue = FetchMode.eager.index; final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index; final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
@ -88,6 +92,27 @@ final int _tabOrderDefaultValue =
final int _markStoriesAsReadWhenPreferenceDefaultValue = final int _markStoriesAsReadWhenPreferenceDefaultValue =
StoryMarkingMode.tap.index; 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 { class SwipeGesturePreference extends BooleanPreference {
const SwipeGesturePreference({bool? val}) const SwipeGesturePreference({bool? val})
: super(val: val ?? _swipeGestureModeDefaultValue); : super(val: val ?? _swipeGestureModeDefaultValue);
@ -335,6 +360,25 @@ class CustomTabPreference extends BooleanPreference {
bool get isDisplayable => Platform.isAndroid; 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 { class HapticFeedbackPreference extends BooleanPreference {
const HapticFeedbackPreference({bool? val}) const HapticFeedbackPreference({bool? val})
: super(val: val ?? _hapticFeedbackModeDefaultValue); : super(val: val ?? _hapticFeedbackModeDefaultValue);
@ -352,9 +396,6 @@ class HapticFeedbackPreference extends BooleanPreference {
@override @override
String get subtitle => ''; String get subtitle => '';
@override
bool get isDisplayable => Platform.isIOS;
} }
class FetchModePreference extends IntPreference { class FetchModePreference extends IntPreference {

View File

@ -1,11 +1,33 @@
import 'dart:io';
import 'package:collection/collection.dart'; 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: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. /// For fetching anything that cannot be fetched through Hacker News API.
class HackerNewsWebRepository { 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 = static const String _favoritesBaseUrl =
'https://news.ycombinator.com/favorites?id='; 'https://news.ycombinator.com/favorites?id=';
@ -15,23 +37,35 @@ class HackerNewsWebRepository {
Future<Iterable<int>> fetchFavorites({required String of}) async { Future<Iterable<int>> fetchFavorites({required String of}) async {
final String username = of; final String username = of;
final List<int> allIds = <int>[]; final List<int> allIds = <int>[];
int page = 0; int page = 1;
const int maxPage = 2;
Future<Iterable<int>> fetchIds(int page) async { Future<Iterable<int>> fetchIds(int page, {bool isComment = false}) async {
final Uri url = Uri.parse('$_favoritesBaseUrl$username&p=$page'); try {
final Response response = await get(url); final Uri url = Uri.parse(
final Document document = parse(response.body); '''$_favoritesBaseUrl$username${isComment ? '&comments=t' : ''}&p=$page''',
final List<Element> elements = document.querySelectorAll(_aThingSelector); );
final Iterable<int> parsedIds = elements final Response<String> response = await _dio.getUri<String>(url);
.map(
(Element e) => int.tryParse(e.id), /// Due to rate limiting, we have a short break here.
) await Future<void>.delayed(AppDurations.twoSeconds);
.whereNotNull();
return parsedIds; 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; Iterable<int> ids;
while (true) { while (page <= maxPage) {
ids = await fetchIds(page); ids = await fetchIds(page);
if (ids.isEmpty) { if (ids.isEmpty) {
break; break;
@ -40,6 +74,192 @@ class HackerNewsWebRepository {
page++; page++;
} }
page = 1;
while (page <= maxPage) {
ids = await fetchIds(page, isComment: true);
if (ids.isEmpty) {
break;
}
allIds.addAll(ids);
page++;
}
return allIds; 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();
}
} }

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.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 /// documents directory assigned by host system which you can retrieve
/// by calling [getApplicationDocumentsDirectory]. /// by calling [getApplicationDocumentsDirectory].
class SembastRepository { class SembastRepository {
SembastRepository({Database? database}) { SembastRepository({
Database? database,
Database? cache,
}) {
if (database == null) { if (database == null) {
initializeDatabase(); initializeDatabase();
} else { } else {
_database = database; _database = database;
} }
if (cache == null) {
initializeCache();
} else {
_cache = cache;
}
} }
Database? _database; Database? _database;
Database? _cache;
List<int>? _idsOfCommentsRepliedToMe; List<int>? _idsOfCommentsRepliedToMe;
static const String _cachedCommentsKey = 'cachedComments'; static const String _cachedCommentsKey = 'cachedComments';
static const String _commentsKey = 'comments'; static const String _commentsKey = 'comments';
static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe'; static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe';
static const String _metadataCacheKey = 'metadata';
Future<Database> initializeDatabase() async { Future<Database> initializeDatabase() async {
final Directory dir = await getApplicationDocumentsDirectory(); final Directory dir = await getApplicationCacheDirectory();
await dir.create(recursive: true); await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db'); final String dbPath = join(dir.path, 'hacki.db');
final DatabaseFactory dbFactory = databaseFactoryIo; final DatabaseFactory dbFactory = databaseFactoryIo;
@ -37,6 +50,16 @@ class SembastRepository {
return db; 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. //#region Cached comments for time machine feature.
Future<Map<String, Object?>> cacheComment(Comment comment) async { Future<Map<String, Object?>> cacheComment(Comment comment) async {
final Database db = _database ?? await initializeDatabase(); final Database db = _database ?? await initializeDatabase();
@ -177,10 +200,50 @@ class SembastRepository {
//#endregion //#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(); final Directory dir = await getApplicationDocumentsDirectory();
await dir.create(recursive: true); await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db'); final String dbPath = join(dir.path, 'hacki.db');
return File(dbPath).delete(); 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();
}
} }

View File

@ -69,6 +69,7 @@ class ItemScreen extends StatefulWidget {
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit( create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(), filterCubit: context.read<FilterCubit>(),
preferenceCubit: context.read<PreferenceCubit>(),
isOfflineReading: isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading, context.read<StoriesBloc>().state.isOfflineReading,
item: args.item, item: args.item,
@ -79,6 +80,8 @@ class ItemScreen extends StatefulWidget {
onlyShowTargetComment: args.onlyShowTargetComment, onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments, targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache, useCommentCache: args.useCommentCache,
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
), ),
), ),
], ],
@ -110,6 +113,7 @@ class ItemScreen extends StatefulWidget {
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit( create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(), filterCubit: context.read<FilterCubit>(),
preferenceCubit: context.read<PreferenceCubit>(),
isOfflineReading: isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading, context.read<StoriesBloc>().state.isOfflineReading,
item: args.item, item: args.item,
@ -121,6 +125,8 @@ class ItemScreen extends StatefulWidget {
)..init( )..init(
onlyShowTargetComment: args.onlyShowTargetComment, onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments, targetAncestors: args.targetComments,
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
), ),
), ),
], ],

View File

@ -2,6 +2,7 @@ import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_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/cubits/comments/comments_cubit.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
@ -65,9 +66,11 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
super.initState(); super.initState();
scrollController.addListener(onScroll); scrollController.addListener(onScroll);
textEditingController.text = widget.commentsCubit.state.inThreadSearchQuery; textEditingController.text = widget.commentsCubit.state.inThreadSearchQuery;
if (textEditingController.text.isEmpty) { Future<void>.delayed(AppDurations.ms300, () {
focusNode.requestFocus(); if (textEditingController.text.isEmpty) {
} focusNode.requestFocus();
}
});
} }
@override @override

View File

@ -59,7 +59,12 @@ class MainView extends StatelessWidget {
if (context.read<StoriesBloc>().state.isOfflineReading == if (context.read<StoriesBloc>().state.isOfflineReading ==
false && false &&
state.onlyShowTargetComment == 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) { if (state.item.isPoll) {
context.read<PollCubit>().refresh(); context.read<PollCubit>().refresh();
@ -145,27 +150,28 @@ class MainView extends StatelessWidget {
}, },
), ),
), ),
Positioned( if (context.read<PreferenceCubit>().state.devModeEnabled)
height: Dimens.pt4, Positioned(
bottom: Dimens.zero, height: Dimens.pt4,
left: Dimens.zero, bottom: Dimens.zero,
right: Dimens.zero, left: Dimens.zero,
child: BlocBuilder<CommentsCubit, CommentsState>( right: Dimens.zero,
buildWhen: (CommentsState prev, CommentsState current) => child: BlocBuilder<CommentsCubit, CommentsState>(
prev.status != current.status, buildWhen: (CommentsState prev, CommentsState current) =>
builder: (BuildContext context, CommentsState state) { prev.status != current.status,
return AnimatedOpacity( builder: (BuildContext context, CommentsState state) {
opacity: state.status == CommentsStatus.inProgress return AnimatedOpacity(
? NumSwitch.on opacity: state.status == CommentsStatus.inProgress
: NumSwitch.off, ? NumSwitch.on
duration: const Duration( : NumSwitch.off,
milliseconds: _loadingIndicatorOpacityAnimationDuration, duration: const Duration(
), milliseconds: _loadingIndicatorOpacityAnimationDuration,
child: const LinearProgressIndicator(), ),
); child: const LinearProgressIndicator(),
}, );
},
),
), ),
),
], ],
); );
} }
@ -250,8 +256,8 @@ class _ParentItemSection extends StatelessWidget {
const Spacer(), const Spacer(),
Text( Text(
item.timeAgo, item.timeAgo,
style: const TextStyle( style: TextStyle(
color: Palette.grey, color: Theme.of(context).metadataColor,
), ),
textScaler: MediaQuery.of(context).textScaler, textScaler: MediaQuery.of(context).textScaler,
), ),

View File

@ -147,22 +147,28 @@ class _ProfileScreenState extends State<ProfileScreen>
builder: ( builder: (
BuildContext context, BuildContext context,
Status status, Status status,
) => ) {
TextButton( return TextButton(
onPressed: () { onPressed: () =>
context.read<FavCubit>().merge(); context.read<FavCubit>().merge(
}, onError: (AppException e) =>
child: status == Status.inProgress showErrorSnackBar(e.message),
? const SizedBox( onSuccess: () => showSnackBar(
height: Dimens.pt12, content: '''Sync completed.''',
width: Dimens.pt12, ),
child: ),
CustomCircularProgressIndicator( child: status == Status.inProgress
strokeWidth: Dimens.pt2, ? const SizedBox(
), height: Dimens.pt12,
) width: Dimens.pt12,
: const Text('Sync from Hacker News'), child:
), CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text('Sync from Hacker News'),
);
},
) )
: null; : null;

View File

@ -89,8 +89,8 @@ class InboxView extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
'''${e.timeAgo} from ${e.by}:''', '''${e.timeAgo} from ${e.by}:''',
style: const TextStyle( style: TextStyle(
color: Palette.grey, color: Theme.of(context).metadataColor,
), ),
), ),
const SizedBox( const SizedBox(

View File

@ -301,6 +301,17 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
title: const Text('About'), title: const Text('About'),
subtitle: const Text('nothing interesting here.'), subtitle: const Text('nothing interesting here.'),
onTap: showAboutHackiDialog, 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( const SizedBox(
height: Dimens.pt48, height: Dimens.pt48,
@ -498,6 +509,12 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
.whenComplete( .whenComplete(
DefaultCacheManager().emptyCache, DefaultCacheManager().emptyCache,
) )
.whenComplete(
locator.get<SembastRepository>().deleteCachedComments,
)
.whenComplete(
locator.get<SembastRepository>().deleteCachedMetadata,
)
.whenComplete(() { .whenComplete(() {
showSnackBar(content: 'Cache cleared!'); showSnackBar(content: 'Cache cleared!');
}); });
@ -645,6 +662,9 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
actionsPadding: const EdgeInsets.all(
Dimens.pt16,
),
actions: <Widget>[ actions: <Widget>[
ElevatedButton( ElevatedButton(
onPressed: onSendEmailTapped, onPressed: onSendEmailTapped,

View File

@ -188,20 +188,21 @@ class CommentTile extends StatelessWidget {
color: Palette.grey, color: Palette.grey,
), ),
), ),
if (!comment.dead && isNew) // Commented out for now, maybe review later.
const Padding( // if (!comment.dead && isNew)
padding: EdgeInsets.only(left: 4), // const Padding(
child: Icon( // padding: EdgeInsets.only(left: 4),
Icons.sunny_snowing, // child: Icon(
size: 16, // Icons.sunny_snowing,
color: Palette.grey, // size: 16,
), // color: Palette.grey,
), // ),
// ),
const Spacer(), const Spacer(),
Text( Text(
comment.timeAgo, comment.timeAgo,
style: const TextStyle( style: TextStyle(
color: Palette.grey, color: Theme.of(context).metadataColor,
), ),
textScaler: MediaQuery.of(context).textScaler, textScaler: MediaQuery.of(context).textScaler,
), ),

View File

@ -97,8 +97,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
showAuthor showAuthor
? '''${e.timeAgo} by ${e.by}''' ? '''${e.timeAgo} by ${e.by}'''
: e.timeAgo, : e.timeAgo,
style: const TextStyle( style: TextStyle(
color: Palette.grey, color: Theme.of(context).metadataColor,
), ),
), ),
const SizedBox( const SizedBox(
@ -147,6 +147,11 @@ class ItemsListView<T extends Item> extends StatelessWidget {
if (useSimpleTileForStory || !showWebPreviewOnStoryTile) if (useSimpleTileForStory || !showWebPreviewOnStoryTile)
const Divider( const Divider(
height: Dimens.zero, height: Dimens.zero,
)
else if (context.read<SplitViewCubit>().state.enabled)
const Divider(
height: Dimens.pt6,
color: Palette.transparent,
), ),
]; ];
} else if (e is Comment) { } else if (e is Comment) {
@ -186,8 +191,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
showAuthor showAuthor
? '''${e.timeAgo} by ${e.by}''' ? '''${e.timeAgo} by ${e.by}'''
: e.timeAgo, : e.timeAgo,
style: const TextStyle( style: TextStyle(
color: Palette.grey, color: Theme.of(context).metadataColor,
), ),
), ),
const SizedBox( const SizedBox(

View File

@ -119,10 +119,15 @@ class LinkView extends StatelessWidget {
: CachedNetworkImage( : CachedNetworkImage(
imageUrl: imageUri!, imageUrl: imageUri!,
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth, fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
memCacheHeight: layoutHeight.toInt() * 4,
cacheKey: imageUri, cacheKey: imageUri,
errorWidget: (_, __, ___) => errorWidget: (_, __, ___) => Center(
const SizedBox.shrink(), child: Text(
r'¯\_(ツ)_/¯',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
),
),
), ),
), ),
), ),

View File

@ -17,7 +17,6 @@ class _OnboardingViewState extends State<OnboardingView> {
final Throttle throttle = Throttle(delay: _throttleDelay); final Throttle throttle = Throttle(delay: _throttleDelay);
static const Duration _throttleDelay = AppDurations.ms100; static const Duration _throttleDelay = AppDurations.ms100;
static const double _screenshotHeight = 600;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -44,7 +43,7 @@ class _OnboardingViewState extends State<OnboardingView> {
left: Dimens.zero, left: Dimens.zero,
right: Dimens.zero, right: Dimens.zero,
child: SizedBox( child: SizedBox(
height: _screenshotHeight, height: MediaQuery.of(context).size.height * 0.8,
child: PageView( child: PageView(
controller: pageController, controller: pageController,
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
@ -69,7 +68,7 @@ class _OnboardingViewState extends State<OnboardingView> {
), ),
), ),
Positioned( Positioned(
bottom: Dimens.pt40, bottom: MediaQuery.of(context).viewPadding.bottom,
left: Dimens.zero, left: Dimens.zero,
right: Dimens.zero, right: Dimens.zero,
child: ElevatedButton( child: ElevatedButton(
@ -115,16 +114,14 @@ class _PageViewChild extends StatelessWidget {
final String path; final String path;
final String description; final String description;
static const double _height = 500;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
Material( Material(
elevation: 8, elevation: Dimens.pt8,
child: SizedBox( child: SizedBox(
height: _height, height: MediaQuery.of(context).size.height * 0.5,
child: Image.asset(path), child: Image.asset(path),
), ),
), ),

View File

@ -14,9 +14,9 @@ import 'package:html/dom.dart' hide Comment, Text;
import 'package:html/parser.dart' as parser; import 'package:html/parser.dart' as parser;
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:http/io_client.dart'; import 'package:http/io_client.dart';
import 'package:logger/logger.dart';
abstract class InfoBase { abstract class InfoBase {
late DateTime _timeout;
late bool _shouldRetry; late bool _shouldRetry;
Map<String, dynamic> toJson(); Map<String, dynamic> toJson();
@ -97,13 +97,10 @@ class WebAnalyzer {
/// Get web information /// Get web information
/// return [InfoBase] /// return [InfoBase]
static InfoBase? getInfoFromCache(String? cacheKey) { static InfoBase? getInfoFromCache(String? cacheKey) {
if (cacheKey == null) return null;
final InfoBase? info = cacheMap[cacheKey]; final InfoBase? info = cacheMap[cacheKey];
if (info != null) {
if (!info._timeout.isAfter(DateTime.now())) {
cacheMap.remove(cacheKey);
}
}
return info; return info;
} }
@ -118,23 +115,31 @@ class WebAnalyzer {
final String key = getKey(story); final String key = getKey(story);
final String url = story.url; final String url = story.url;
/// [1] Try to fetch from mem cache.
InfoBase? info = getInfoFromCache(key); 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) { if (story.url.isEmpty && story.text.isNotEmpty) {
info = WebInfo( info = WebInfo(
title: story.title, title: story.title,
description: story.text, description: story.text,
) ).._shouldRetry = false;
.._timeout = DateTime.now().add(cache)
.._shouldRetry = false;
cacheMap[key] = info; cacheMap[key] = info;
return info; return info;
} }
/// [3] If in offline mode, use comment text for description.
if (offlineReading) { if (offlineReading) {
int index = 0; int index = 0;
Comment? comment; Comment? comment;
@ -149,9 +154,7 @@ class WebAnalyzer {
info = WebInfo( info = WebInfo(
title: story.title, title: story.title,
description: comment != null ? '${comment.by}: ${comment.text}' : null, description: comment != null ? '${comment.by}: ${comment.text}' : null,
) ).._shouldRetry = false;
.._shouldRetry = false
.._timeout = DateTime.now();
cacheMap[key] = info; cacheMap[key] = info;
@ -159,15 +162,41 @@ class WebAnalyzer {
} }
try { 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( info = await _getInfoByIsolate(
url: url, url: url,
multimedia: multimedia, multimedia: multimedia,
story: story, story: story,
); );
/// [7] If web analyzing was successful, cache it in both mem and file.
if (info != null && !info._shouldRetry) { if (info != null && !info._shouldRetry) {
info._timeout = DateTime.now().add(cache);
cacheMap[key] = info; 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; return info;
@ -175,9 +204,7 @@ class WebAnalyzer {
return WebInfo( return WebInfo(
title: story.title, title: story.title,
description: story.text, description: story.text,
) ).._shouldRetry = true;
.._shouldRetry = true
.._timeout = DateTime.now();
} }
} }
@ -393,10 +420,9 @@ class WebAnalyzer {
try { try {
html = gbk.decode(response.bodyBytes); html = gbk.decode(response.bodyBytes);
} catch (e) { } catch (e) {
// locator.get<Logger>().log( locator
// Level.error, .get<Logger>()
// 'Web page resolution failure from:$url Error:$e', .e('''Web page resolution failure from:$url Error:$e''');
// );
} }
} }

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
extension ThemeDataExtension on ThemeData { 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);
} }

View File

@ -14,4 +14,10 @@ abstract class HapticFeedbackUtil {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
} }
} }
static void heavy() {
if (enabled) {
HapticFeedback.heavyImpact();
}
}
} }

View File

@ -1446,4 +1446,4 @@ packages:
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.2.0-194.0.dev <4.0.0" dart: ">=3.2.0-194.0.dev <4.0.0"
flutter: ">=3.16.2" flutter: ">=3.16.3"

View File

@ -1,11 +1,11 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 2.5.0+134 version: 2.6.0+135
publish_to: none publish_to: none
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"
flutter: "3.16.2" flutter: "3.16.3"
dependencies: dependencies:
adaptive_theme: ^3.2.0 adaptive_theme: ^3.2.0