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 _authRepository.logout();
|
||||||
await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
|
await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
|
||||||
await _sembastRepository.deleteAll();
|
await _sembastRepository.deleteCachedComments();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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?;
|
||||||
|
@ -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,
|
||||||
|
@ -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),
|
||||||
|
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> 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';
|
||||||
|
|
||||||
|
@ -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';
|
||||||
|
@ -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 {
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
),
|
),
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
),
|
),
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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''');
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -14,4 +14,10 @@ abstract class HapticFeedbackUtil {
|
|||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void heavy() {
|
||||||
|
if (enabled) {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
Submodule submodules/flutter updated: 9e1c857886...b0366e0a3f
Reference in New Issue
Block a user