Compare commits

...

5 Commits

13 changed files with 184 additions and 90 deletions

View File

@ -60,7 +60,7 @@ void main() {
expect(firstStoryFinder, findsOneWidget); expect(firstStoryFinder, findsOneWidget);
await tester.tap(firstStoryFinder); await tester.tap(firstStoryFinder);
await tester.pump(const Duration(seconds: 4)); await tester.pump(const Duration(seconds: 5));
}, },
reportKey: 'scrolling_timeline', reportKey: 'scrolling_timeline',
); );

View File

@ -156,6 +156,6 @@ SPEC CHECKSUMS:
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1 webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
PODFILE CHECKSUM: 4f065ae0c88ce0d29cd933be6950e8da0fa046fc PODFILE CHECKSUM: f03c7c11cf2b623592c89c68c628682778bb78b4
COCOAPODS: 1.15.2 COCOAPODS: 1.15.2

View File

@ -126,7 +126,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
.copyWithStatusUpdated(type: type, to: Status.inProgress), .copyWithStatusUpdated(type: type, to: Status.inProgress),
); );
_offlineRepository _offlineRepository
.getCachedStoriesStream(ids: ids.sublist(0, _pageSize)) .getCachedStoriesStream(
ids: ids.sublist(0, min(_pageSize, ids.length)),
)
.listen((Story story) => add(StoryLoaded(story: story, type: type))) .listen((Story story) => add(StoryLoaded(story: story, type: type)))
.onDone(() => add(StoryLoadingCompleted(type: type))); .onDone(() => add(StoryLoadingCompleted(type: type)));
} else if (event.useApi || state.dataSource == HackerNewsDataSource.api) { } else if (event.useApi || state.dataSource == HackerNewsDataSource.api) {
@ -142,7 +144,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> with Loggable {
await _hackerNewsRepository await _hackerNewsRepository
.fetchStoriesStream( .fetchStoriesStream(
ids: ids.sublist(0, _pageSize), ids: ids.sublist(0, min(_pageSize, ids.length)),
sequential: true, sequential: true,
) )
.listen((Story story) { .listen((Story story) {

View File

@ -11,12 +11,19 @@ class AppException implements Exception {
} }
class RateLimitedException extends AppException { class RateLimitedException extends AppException {
RateLimitedException() : super(message: 'Rate limited...'); RateLimitedException(this.statusCode)
: super(message: 'Rate limited ($statusCode)...');
final int? statusCode;
} }
class RateLimitedWithFallbackException extends AppException { class RateLimitedWithFallbackException extends AppException {
RateLimitedWithFallbackException() RateLimitedWithFallbackException(this.statusCode)
: super(message: 'Rate limited, fetching from API instead...'); : super(
message: 'Rate limited ($statusCode), fetching from API instead...',
);
final int? statusCode;
} }
class PossibleParsingException extends AppException { class PossibleParsingException extends AppException {

View File

@ -110,11 +110,11 @@ final class SwipeGesturePreference extends BooleanPreference {
String get key => 'swipeGestureMode'; String get key => 'swipeGestureMode';
@override @override
String get title => 'Swipe Gesture'; String get title => 'Swipe Gesture for Switching Tabs';
@override @override
String get subtitle => String get subtitle =>
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.'''; '''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu and double tap to open the url (if complex tile is disabled).''';
} }
final class NotificationModePreference extends BooleanPreference { final class NotificationModePreference extends BooleanPreference {

View File

@ -5,10 +5,12 @@ import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/hacker_news_repository.dart'; import 'package:hacki/repositories/hacker_news_repository.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -17,13 +19,18 @@ import 'package:html/parser.dart';
import 'package:html_unescape/html_unescape.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 with Loggable {
HackerNewsWebRepository({ HackerNewsWebRepository({
RemoteConfigCubit? remoteConfigCubit, RemoteConfigCubit? remoteConfigCubit,
HackerNewsRepository? hackerNewsRepository, HackerNewsRepository? hackerNewsRepository,
Dio? dioWithCache, Dio? dioWithCache,
Dio? dio, Dio? dio,
}) : _dio = dio ?? Dio(), }) : _dio = dio ?? Dio()
..interceptors.addAll(
<Interceptor>[
if (kDebugMode) LoggerInterceptor(),
],
),
_dioWithCache = dioWithCache ?? Dio() _dioWithCache = dioWithCache ?? Dio()
..interceptors.addAll( ..interceptors.addAll(
<Interceptor>[ <Interceptor>[
@ -34,10 +41,18 @@ class HackerNewsWebRepository {
_remoteConfigCubit = _remoteConfigCubit =
remoteConfigCubit ?? locator.get<RemoteConfigCubit>(), remoteConfigCubit ?? locator.get<RemoteConfigCubit>(),
_hackerNewsRepository = _hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(); hackerNewsRepository ?? locator.get<HackerNewsRepository>() {
_dio.interceptors.add(RetryInterceptor(dio: _dio));
}
/// The client for fetching comments. We should be careful
/// while fetching comments because it will easily trigger
/// 503 from the server.
final Dio _dioWithCache; final Dio _dioWithCache;
/// The client for fetching stories.
final Dio _dio; final Dio _dio;
final RemoteConfigCubit _remoteConfigCubit; final RemoteConfigCubit _remoteConfigCubit;
final HackerNewsRepository _hackerNewsRepository; final HackerNewsRepository _hackerNewsRepository;
@ -66,6 +81,10 @@ class HackerNewsWebRepository {
String get _moreLinkSelector => _remoteConfigCubit.state.moreLinkSelector; String get _moreLinkSelector => _remoteConfigCubit.state.moreLinkSelector;
static final Map<int, int> _next = <int, int>{}; static final Map<int, int> _next = <int, int>{};
static const List<int> _rateLimitedStatusCode = <int>[
HttpStatus.forbidden,
HttpStatus.serviceUnavailable,
];
Stream<Story> fetchStoriesStream( Stream<Story> fetchStoriesStream(
StoryType storyType, { StoryType storyType, {
@ -84,6 +103,7 @@ class HackerNewsWebRepository {
StoryType.latest => StoryType.latest =>
'$_storiesBaseUrl/${storyType.webPathParam}?next=${_next[page]}' '$_storiesBaseUrl/${storyType.webPathParam}?next=${_next[page]}'
}; };
final Uri url = Uri.parse(urlStr); final Uri url = Uri.parse(urlStr);
final Options option = Options( final Options option = Options(
headers: _headers, headers: _headers,
@ -125,8 +145,9 @@ class HackerNewsWebRepository {
(elements.elementAt(index), subtextElements.elementAt(index)), (elements.elementAt(index), subtextElements.elementAt(index)),
); );
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) { logError('error fetching stories on page $page: $e');
throw RateLimitedWithFallbackException(); if (_rateLimitedStatusCode.contains(e.response?.statusCode)) {
throw RateLimitedWithFallbackException(e.response?.statusCode);
} }
throw GenericException(); throw GenericException();
} }
@ -260,8 +281,9 @@ class HackerNewsWebRepository {
elements.map((Element e) => int.tryParse(e.id)).whereNotNull(); elements.map((Element e) => int.tryParse(e.id)).whereNotNull();
return parsedIds; return parsedIds;
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) { if (_rateLimitedStatusCode.contains(e.response?.statusCode)) {
throw RateLimitedException(); logError('error fetching favorites on page $page: $e');
throw RateLimitedException(e.response?.statusCode);
} }
throw GenericException(); throw GenericException();
} }
@ -338,8 +360,9 @@ class HackerNewsWebRepository {
document.querySelectorAll(_athingComtrSelector); document.querySelectorAll(_athingComtrSelector);
return elements; return elements;
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) { if (_rateLimitedStatusCode.contains(e.response?.statusCode)) {
throw RateLimitedWithFallbackException(); logError('error fetching comments on page $page: $e');
throw RateLimitedWithFallbackException(e.response?.statusCode);
} }
throw GenericException(); throw GenericException();
} }
@ -484,4 +507,7 @@ class HackerNewsWebRepository {
) )
.trim(); .trim();
} }
@override
String get logIdentifier => 'HackerNewsWebRepository';
} }

View File

@ -14,7 +14,7 @@ class TabletHomeScreen extends StatelessWidget {
}); });
final Widget homeScreen; final Widget homeScreen;
static const double _dragPanelWidth = Dimens.pt6; static const double _dragPanelWidth = Dimens.pt2;
static const double _dragDotHeight = Dimens.pt30; static const double _dragDotHeight = Dimens.pt30;
@override @override
@ -80,54 +80,60 @@ class TabletHomeScreen extends StatelessWidget {
curve: Curves.elasticOut, curve: Curves.elasticOut,
child: const _TabletStoryView(), child: const _TabletStoryView(),
), ),
Positioned( if (!state.expanded) ...<Widget>[
left: submissionPanelWidth, Positioned(
top: Dimens.zero, left: submissionPanelWidth,
bottom: Dimens.zero, top: Dimens.zero,
width: _dragPanelWidth, bottom: Dimens.zero,
child: GestureDetector( width: _dragPanelWidth,
onHorizontalDragUpdate: (DragUpdateDetails details) { child: GestureDetector(
context.read<SplitViewCubit>().updateSubmissionPanelWidth( onHorizontalDragUpdate: (DragUpdateDetails details) {
details.globalPosition.dx, context
); .read<SplitViewCubit>()
}, .updateSubmissionPanelWidth(
child: ColoredBox( details.globalPosition.dx,
color: Theme.of(context).colorScheme.tertiary, );
child: const SizedBox.shrink(), },
child: ColoredBox(
color: Theme.of(context).colorScheme.tertiary,
child: const SizedBox.shrink(),
),
), ),
), ),
), Positioned(
Positioned( left: submissionPanelWidth +
left: submissionPanelWidth + _dragPanelWidth / 2 -
_dragPanelWidth / 2 - _dragDotHeight / 2,
_dragDotHeight / 2, top: (MediaQuery.of(context).size.height - _dragDotHeight) /
top: 2,
(MediaQuery.of(context).size.height - _dragDotHeight) / 2, height: _dragDotHeight,
height: _dragDotHeight, width: _dragDotHeight,
width: _dragDotHeight, child: GestureDetector(
child: GestureDetector( onHorizontalDragUpdate: (DragUpdateDetails details) {
onHorizontalDragUpdate: (DragUpdateDetails details) { context
context.read<SplitViewCubit>().updateSubmissionPanelWidth( .read<SplitViewCubit>()
details.globalPosition.dx, .updateSubmissionPanelWidth(
); details.globalPosition.dx,
}, );
child: Container( },
width: _dragDotHeight, child: Container(
height: _dragDotHeight, width: _dragDotHeight,
decoration: BoxDecoration( height: _dragDotHeight,
color: Theme.of(context).colorScheme.tertiary, decoration: BoxDecoration(
shape: BoxShape.circle, color: Theme.of(context).colorScheme.tertiary,
), shape: BoxShape.circle,
child: Center( ),
child: FaIcon( child: Center(
FontAwesomeIcons.gripLinesVertical, child: FaIcon(
color: Theme.of(context).colorScheme.onTertiary, FontAwesomeIcons.gripLinesVertical,
size: TextDimens.pt16, color: Theme.of(context).colorScheme.onTertiary,
size: TextDimens.pt16,
),
), ),
), ),
), ),
), ),
), ],
], ],
); );
}, },

View File

@ -82,6 +82,13 @@ class ItemsListView<T extends Item> extends StatelessWidget {
FadeIn( FadeIn(
child: InkWell( child: InkWell(
onTap: () => onTap(e), onTap: () => onTap(e),
/// If swipe gesture is enabled on home screen, use
/// long press instead of slide action to trigger
/// the action menu.
onLongPress: swipeGestureEnabled
? () => onMoreTapped?.call(e, context.rect)
: null,
child: Padding( child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: Dimens.pt8, top: Dimens.pt8,

View File

@ -115,37 +115,74 @@ class LinkView extends StatelessWidget {
child: SizedBox( child: SizedBox(
height: layoutHeight, height: layoutHeight,
width: layoutHeight, width: layoutHeight,
child: CachedNetworkImage( child: imageUri == null && url.isEmpty
imageUrl: imageUri ?? '', ? FadeIn(
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
cacheKey: imageUri,
errorWidget: (_, __, ___) {
if (url.isEmpty) {
return FadeIn(
child: Center( child: Center(
child: _HackerNewsImage( child: _HackerNewsImage(
height: layoutHeight, height: layoutHeight,
), ),
), ),
); )
} : () {
return Center( if (imageUri?.isNotEmpty ?? false) {
child: CachedNetworkImage( return CachedNetworkImage(
imageUrl: Constants.favicon(url), imageUrl: imageUri!,
fit: BoxFit.scaleDown, fit:
cacheKey: iconUri, isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
errorWidget: (_, __, ___) { cacheKey: imageUri,
return const FadeIn( errorWidget: (_, __, ___) {
child: Icon( if (url.isEmpty) {
Icons.public, return FadeIn(
size: Dimens.pt20, child: Center(
child: _HackerNewsImage(
height: layoutHeight,
),
),
);
}
return Center(
child: CachedNetworkImage(
imageUrl: Constants.favicon(url),
fit: BoxFit.scaleDown,
cacheKey: iconUri,
errorWidget: (_, __, ___) {
return const FadeIn(
child: Icon(
Icons.public,
size: Dimens.pt20,
),
);
},
),
);
},
);
} else if (url.isNotEmpty) {
return Center(
child: CachedNetworkImage(
imageUrl: Constants.favicon(url),
fit: BoxFit.scaleDown,
cacheKey: iconUri,
errorWidget: (_, __, ___) {
return const FadeIn(
child: Icon(
Icons.public,
size: Dimens.pt20,
),
);
},
), ),
); );
}, } else {
), return FadeIn(
); child: Center(
}, child: _HackerNewsImage(
), height: layoutHeight,
),
),
);
}
}(),
), ),
), ),
) )

View File

@ -128,7 +128,7 @@ class StoryTile extends StatelessWidget {
excludeSemantics: true, excludeSemantics: true,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
onLongPress: () { onDoubleTap: () {
if (story.url.isNotEmpty) { if (story.url.isNotEmpty) {
LinkUtil.launch( LinkUtil.launch(
story.url, story.url,

View File

@ -254,6 +254,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.6.0" version: "5.6.0"
dio_smart_retry:
dependency: "direct main"
description:
name: dio_smart_retry
sha256: "3d71450c19b4d91ef4c7d726a55a284bfc11eb3634f1f25006cdfab3f8595653"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
dio_web_adapter: dio_web_adapter:
dependency: transitive dependency: transitive
description: description:
@ -1571,4 +1579,4 @@ packages:
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.4.0 <4.0.0" dart: ">=3.4.0 <4.0.0"
flutter: ">=3.24.2" flutter: ">=3.24.3"

View File

@ -1,11 +1,11 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 2.9.1+149 version: 2.9.3+151
publish_to: none publish_to: none
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"
flutter: "3.24.2" flutter: "3.24.3"
dependencies: dependencies:
adaptive_theme: ^3.2.0 adaptive_theme: ^3.2.0
@ -18,6 +18,7 @@ dependencies:
connectivity_plus: ^6.0.3 connectivity_plus: ^6.0.3
device_info_plus: ^10.1.0 device_info_plus: ^10.1.0
dio: ^5.4.3+1 dio: ^5.4.3+1
dio_smart_retry: ^6.0.0
equatable: ^2.0.5 equatable: ^2.0.5
fast_gbk: ^1.0.0 fast_gbk: ^1.0.0
feature_discovery: feature_discovery: