Compare commits

...

11 Commits

29 changed files with 569 additions and 219 deletions

View File

@ -63,18 +63,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.4" version: "10.0.5"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@ -95,18 +95,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.15.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -164,10 +164,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.2"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -180,10 +180,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.5"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.3.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View File

@ -0,0 +1 @@
- Improved tablet mode, you can now resize submission panel.

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

@ -46,7 +46,7 @@
<string>mailto</string> <string>mailto</string>
</array> </array>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>15.0</string> <string>14.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>

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

@ -19,6 +19,7 @@ class FavCubit extends Cubit<FavState> with Loggable {
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
HackerNewsRepository? hackerNewsRepository, HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository, HackerNewsWebRepository? hackerNewsWebRepository,
SembastRepository? sembastRepository,
}) : _authBloc = authBloc, }) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(), _authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository = _preferenceRepository =
@ -27,6 +28,8 @@ class FavCubit extends Cubit<FavState> with Loggable {
hackerNewsRepository ?? locator.get<HackerNewsRepository>(), hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository = _hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(), hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
super(FavState.init()) { super(FavState.init()) {
init(); init();
} }
@ -36,8 +39,9 @@ class FavCubit extends Cubit<FavState> with Loggable {
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final HackerNewsRepository _hackerNewsRepository; final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository; final HackerNewsWebRepository _hackerNewsWebRepository;
final SembastRepository _sembastRepository;
late final StreamSubscription<String>? _usernameSubscription; late final StreamSubscription<String>? _usernameSubscription;
static const int _pageSize = 20; static const int _pageSize = 100;
Future<void> init() async { Future<void> init() async {
_usernameSubscription = _authBloc.stream _usernameSubscription = _authBloc.stream
@ -55,6 +59,8 @@ class FavCubit extends Cubit<FavState> with Loggable {
_hackerNewsRepository _hackerNewsRepository
.fetchItemsStream( .fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)), ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
getFromCache: (int id) =>
_sembastRepository.getCachedItem(id: id),
) )
.listen(_onItemLoaded) .listen(_onItemLoaded)
.onDone(() { .onDone(() {
@ -97,7 +103,10 @@ class FavCubit extends Cubit<FavState> with Loggable {
void removeFav(int id) { void removeFav(int id) {
_preferenceRepository _preferenceRepository
..removeFav(username: username, id: id) ..removeFav(username: username, id: id)
..removeFav(username: '', id: id); ..removeFav(
username: '',
id: id,
);
emit( emit(
state.copyWith( state.copyWith(
@ -200,6 +209,7 @@ class FavCubit extends Cubit<FavState> with Loggable {
} }
void _onItemLoaded(Item item) { void _onItemLoaded(Item item) {
_sembastRepository.cacheItem(item);
emit( emit(
state.copyWith( state.copyWith(
favItems: List<Item>.from(state.favItems)..add(item), favItems: List<Item>.from(state.favItems)..add(item),
@ -207,6 +217,9 @@ class FavCubit extends Cubit<FavState> with Loggable {
); );
} }
void switchTab() =>
emit(state.copyWith(isDisplayingStories: !state.isDisplayingStories));
@override @override
Future<void> close() { Future<void> close() {
_usernameSubscription?.cancel(); _usernameSubscription?.cancel();

View File

@ -7,6 +7,7 @@ class FavState extends Equatable {
required this.status, required this.status,
required this.mergeStatus, required this.mergeStatus,
required this.currentPage, required this.currentPage,
required this.isDisplayingStories,
}); });
FavState.init() FavState.init()
@ -14,13 +15,21 @@ class FavState extends Equatable {
favItems = <Item>[], favItems = <Item>[],
status = Status.idle, status = Status.idle,
mergeStatus = Status.idle, mergeStatus = Status.idle,
currentPage = 0; currentPage = 0,
isDisplayingStories = true;
final List<int> favIds; final List<int> favIds;
final List<Item> favItems; final List<Item> favItems;
final Status status; final Status status;
final Status mergeStatus; final Status mergeStatus;
final int currentPage; final int currentPage;
final bool isDisplayingStories;
List<Comment> get favComments =>
favItems.whereType<Comment>().toList(growable: false);
List<Story> get favStories =>
favItems.whereType<Story>().toList(growable: false);
FavState copyWith({ FavState copyWith({
List<int>? favIds, List<int>? favIds,
@ -28,6 +37,7 @@ class FavState extends Equatable {
Status? status, Status? status,
Status? mergeStatus, Status? mergeStatus,
int? currentPage, int? currentPage,
bool? isDisplayingStories,
}) { }) {
return FavState( return FavState(
favIds: favIds ?? this.favIds, favIds: favIds ?? this.favIds,
@ -35,6 +45,7 @@ class FavState extends Equatable {
status: status ?? this.status, status: status ?? this.status,
mergeStatus: mergeStatus ?? this.mergeStatus, mergeStatus: mergeStatus ?? this.mergeStatus,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
isDisplayingStories: isDisplayingStories ?? this.isDisplayingStories,
); );
} }
@ -45,5 +56,6 @@ class FavState extends Equatable {
currentPage, currentPage,
favIds, favIds,
favItems, favItems,
isDisplayingStories,
]; ];
} }

View File

@ -1,13 +1,14 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
part 'split_view_state.dart'; part 'split_view_state.dart';
class SplitViewCubit extends Cubit<SplitViewState> with Loggable { class SplitViewCubit extends HydratedCubit<SplitViewState> with Loggable {
SplitViewCubit({ SplitViewCubit({
CommentCache? commentCache, CommentCache? commentCache,
}) : _commentCache = commentCache ?? locator.get<CommentCache>(), }) : _commentCache = commentCache ?? locator.get<CommentCache>(),
@ -25,8 +26,36 @@ class SplitViewCubit extends Cubit<SplitViewState> with Loggable {
void disableSplitView() => emit(state.copyWith(enabled: false)); void disableSplitView() => emit(state.copyWith(enabled: false));
void zoom() => emit(state.copyWith(expanded: !state.expanded)); void zoom() => emit(
state.copyWith(
expanded: !state.expanded,
resizingAnimationDuration: AppDurations.ms300,
),
);
void updateSubmissionPanelWidth(double width) => emit(
state.copyWith(
submissionPanelWidth: width,
resizingAnimationDuration: Duration.zero,
),
);
@override @override
String get logIdentifier => '[SplitViewCubit]'; String get logIdentifier => '[SplitViewCubit]';
static const String _submissionPanelWidthKey = 'submissionPanelWidth';
@override
SplitViewState? fromJson(Map<String, dynamic> json) {
return state.copyWith(
submissionPanelWidth: json[_submissionPanelWidthKey] as double?,
);
}
@override
Map<String, dynamic>? toJson(SplitViewState state) {
return <String, dynamic>{
_submissionPanelWidthKey: state.submissionPanelWidth,
};
}
} }

View File

@ -5,25 +5,36 @@ class SplitViewState extends Equatable {
required this.itemScreenArgs, required this.itemScreenArgs,
required this.expanded, required this.expanded,
required this.enabled, required this.enabled,
required this.resizingAnimationDuration,
this.submissionPanelWidth,
}); });
const SplitViewState.init() const SplitViewState.init()
: enabled = false, : enabled = false,
expanded = false, expanded = false,
submissionPanelWidth = null,
resizingAnimationDuration = Duration.zero,
itemScreenArgs = null; itemScreenArgs = null;
final bool enabled; final bool enabled;
final bool expanded; final bool expanded;
final double? submissionPanelWidth;
final Duration resizingAnimationDuration;
final ItemScreenArgs? itemScreenArgs; final ItemScreenArgs? itemScreenArgs;
SplitViewState copyWith({ SplitViewState copyWith({
bool? enabled, bool? enabled,
bool? expanded, bool? expanded,
double? submissionPanelWidth,
Duration? resizingAnimationDuration,
ItemScreenArgs? itemScreenArgs, ItemScreenArgs? itemScreenArgs,
}) { }) {
return SplitViewState( return SplitViewState(
enabled: enabled ?? this.enabled, enabled: enabled ?? this.enabled,
expanded: expanded ?? this.expanded, expanded: expanded ?? this.expanded,
submissionPanelWidth: submissionPanelWidth ?? this.submissionPanelWidth,
resizingAnimationDuration:
resizingAnimationDuration ?? this.resizingAnimationDuration,
itemScreenArgs: itemScreenArgs ?? this.itemScreenArgs, itemScreenArgs: itemScreenArgs ?? this.itemScreenArgs,
); );
} }
@ -32,6 +43,8 @@ class SplitViewState extends Equatable {
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
enabled, enabled,
expanded, expanded,
submissionPanelWidth,
resizingAnimationDuration,
itemScreenArgs, itemScreenArgs,
]; ];
} }

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

@ -41,6 +41,7 @@ class BuildableComment extends Comment with Buildable {
BuildableComment copyWith({ BuildableComment copyWith({
int? level, int? level,
bool? hidden, bool? hidden,
int? kid,
}) { }) {
return BuildableComment( return BuildableComment(
id: id, id: id,
@ -49,7 +50,7 @@ class BuildableComment extends Comment with Buildable {
score: score, score: score,
by: by, by: by,
text: text, text: text,
kids: kids, kids: kid == null ? kids : <int>[...kids, kid],
dead: dead, dead: dead,
deleted: deleted, deleted: deleted,
hidden: hidden ?? this.hidden, hidden: hidden ?? this.hidden,

View File

@ -36,6 +36,7 @@ class Comment extends Item {
Comment copyWith({ Comment copyWith({
int? level, int? level,
bool? hidden, bool? hidden,
int? kid,
}) { }) {
return Comment( return Comment(
id: id, id: id,
@ -44,7 +45,7 @@ class Comment extends Item {
score: score, score: score,
by: by, by: by,
text: text, text: text,
kids: kids, kids: kid == null ? kids : <int>[...kids, kid],
dead: dead, dead: dead,
deleted: deleted, deleted: deleted,
hidden: hidden ?? this.hidden, hidden: hidden ?? this.hidden,

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

@ -302,24 +302,32 @@ class HackerNewsRepository with Loggable {
/// Fetch a list of [Item] based on ids and return results /// Fetch a list of [Item] based on ids and return results
/// using a stream. /// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* { Stream<Item> fetchItemsStream({
required List<int> ids,
Future<Item?> Function(int)? getFromCache,
}) async* {
for (final int id in ids) { for (final int id in ids) {
final Item? item = final Item? cachedItem = await getFromCache?.call(id);
await _fetchItemJson(id).then((Map<String, dynamic>? json) async { if (cachedItem != null) {
if (json == null) return null; yield cachedItem;
} else {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
if (json.isStory) { if (json.isStory) {
final Story story = Story.fromJson(json); final Story story = Story.fromJson(json);
return story; return story;
} else if (json.isComment) { } else if (json.isComment) {
final Comment comment = Comment.fromJson(json); final Comment comment = Comment.fromJson(json);
return comment; return comment;
}
return null;
});
if (item != null) {
yield item;
} }
return null;
});
if (item != null) {
yield item;
} }
} }
} }

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

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
@ -10,6 +11,8 @@ import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart'; import 'package:sembast/sembast_io.dart';
/// [SembastRepository] is for storing stories and comments for faster loading. /// [SembastRepository] is for storing stories and comments for faster loading.
/// This is currently used by [TimeMachineCubit], [NotificationCubit] and
/// [FavCubit].
/// ///
/// Sembast [Database] is used as its database and is being stored in the /// Sembast [Database] is used as its database and is being stored in the
/// documents directory assigned by host system which you can retrieve /// documents directory assigned by host system which you can retrieve
@ -67,7 +70,7 @@ class SembastRepository with Loggable {
return db; return db;
} }
//#region Cached comments for time machine feature. //#region Cached comments for time machine feature and favorites screen.
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();
final StoreRef<int, Map<String, Object?>> store = final StoreRef<int, Map<String, Object?>> store =
@ -89,7 +92,34 @@ class SembastRepository with Loggable {
} }
} }
Future<int> deleteAllCachedComments() async { Future<Map<String, Object?>> cacheItem(Item item) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);
return store.record(item.id).put(db, item.toJson());
}
Future<Item?> getCachedItem({required int id}) async {
final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey);
final RecordSnapshot<int, Map<String, Object?>>? snapshot =
await store.record(id).getSnapshot(db);
if (snapshot != null) {
final bool isStory = snapshot['type'] == 'story';
if (isStory) {
final Story story = Story.fromJson(snapshot.value);
return story;
} else {
final Comment comment = Comment.fromJson(snapshot.value);
return comment;
}
} else {
return null;
}
}
Future<int> deleteAllCachedItems() async {
final Database db = _database ?? await initializeDatabase(); final Database db = _database ?? await initializeDatabase();
final StoreRef<int, Map<String, Object?>> store = final StoreRef<int, Map<String, Object?>> store =
intMapStoreFactory.store(_cachedCommentsKey); intMapStoreFactory.store(_cachedCommentsKey);

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart' hide Badge; import 'package:flutter/material.dart' hide Badge;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
@ -14,6 +14,8 @@ class TabletHomeScreen extends StatelessWidget {
}); });
final Widget homeScreen; final Widget homeScreen;
static const double _dragPanelWidth = Dimens.pt2;
static const double _dragDotHeight = Dimens.pt30;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -28,16 +30,26 @@ class TabletHomeScreen extends StatelessWidget {
return BlocBuilder<SplitViewCubit, SplitViewState>( return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) => buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.expanded != current.expanded, previous.expanded != current.expanded ||
previous.submissionPanelWidth != current.submissionPanelWidth,
builder: (BuildContext context, SplitViewState state) { builder: (BuildContext context, SplitViewState state) {
double submissionPanelWidth =
state.submissionPanelWidth ?? homeScreenWidth;
/// Prevent overflow after orientation change.
if (submissionPanelWidth > MediaQuery.of(context).size.width) {
submissionPanelWidth =
MediaQuery.of(context).size.width - Dimens.pt64;
}
return Stack( return Stack(
children: <Widget>[ children: <Widget>[
AnimatedPositioned( AnimatedPositioned(
left: Dimens.zero, left: Dimens.zero,
top: Dimens.zero, top: Dimens.zero,
bottom: Dimens.zero, bottom: Dimens.zero,
width: homeScreenWidth, width: submissionPanelWidth,
duration: AppDurations.ms300, duration: state.resizingAnimationDuration,
curve: Curves.elasticOut, curve: Curves.elasticOut,
child: homeScreen, child: homeScreen,
), ),
@ -46,7 +58,7 @@ class TabletHomeScreen extends StatelessWidget {
left: Dimens.pt24, left: Dimens.pt24,
bottom: Dimens.pt36, bottom: Dimens.pt36,
height: Dimens.pt40, height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24, width: submissionPanelWidth - Dimens.pt48,
child: const CountdownReminder(), child: const CountdownReminder(),
) )
else else
@ -54,18 +66,74 @@ class TabletHomeScreen extends StatelessWidget {
left: Dimens.pt24, left: Dimens.pt24,
bottom: Dimens.pt36, bottom: Dimens.pt36,
height: Dimens.pt40, height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24, width: submissionPanelWidth - Dimens.pt48,
child: const DownloadProgressReminder(), child: const DownloadProgressReminder(),
), ),
AnimatedPositioned( AnimatedPositioned(
right: Dimens.zero, right: Dimens.zero,
top: Dimens.zero, top: Dimens.zero,
bottom: Dimens.zero, bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth, left: state.expanded
duration: AppDurations.ms300, ? Dimens.zero
: submissionPanelWidth + _dragPanelWidth,
duration: state.resizingAnimationDuration,
curve: Curves.elasticOut, curve: Curves.elasticOut,
child: const _TabletStoryView(), child: const _TabletStoryView(),
), ),
if (!state.expanded) ...<Widget>[
Positioned(
left: submissionPanelWidth,
top: Dimens.zero,
bottom: Dimens.zero,
width: _dragPanelWidth,
child: GestureDetector(
onHorizontalDragUpdate: (DragUpdateDetails details) {
context
.read<SplitViewCubit>()
.updateSubmissionPanelWidth(
details.globalPosition.dx,
);
},
child: ColoredBox(
color: Theme.of(context).colorScheme.tertiary,
child: const SizedBox.shrink(),
),
),
),
Positioned(
left: submissionPanelWidth +
_dragPanelWidth / 2 -
_dragDotHeight / 2,
top: (MediaQuery.of(context).size.height - _dragDotHeight) /
2,
height: _dragDotHeight,
width: _dragDotHeight,
child: GestureDetector(
onHorizontalDragUpdate: (DragUpdateDetails details) {
context
.read<SplitViewCubit>()
.updateSubmissionPanelWidth(
details.globalPosition.dx,
);
},
child: Container(
width: _dragDotHeight,
height: _dragDotHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiary,
shape: BoxShape.circle,
),
child: Center(
child: FaIcon(
FontAwesomeIcons.gripLinesVertical,
color: Theme.of(context).colorScheme.onTertiary,
size: TextDimens.pt16,
),
),
),
),
),
],
], ],
); );
}, },

View File

@ -1,7 +1,5 @@
import 'package:flutter/gestures.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:flutter_slidable/flutter_slidable.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
@ -130,124 +128,12 @@ class _ProfileScreenState extends State<ProfileScreen>
top: Dimens.pt50, top: Dimens.pt50,
child: Visibility( child: Visibility(
visible: pageType == PageType.fav, visible: pageType == PageType.fav,
child: BlocConsumer<FavCubit, FavState>( child: FavoritesScreen(
listener: (BuildContext context, FavState favState) { refreshController: refreshControllerFav,
if (favState.status == Status.success) { authState: authState,
refreshControllerFav onItemTap: (Item item) => goToItemScreen(
..refreshCompleted() args: ItemScreenArgs(item: item),
..loadComplete(); ),
}
},
buildWhen: (FavState previous, FavState current) =>
previous.favItems.length != current.favItems.length,
builder: (BuildContext context, FavState favState) {
Widget? header() => authState.isLoggedIn
? BlocSelector<FavCubit, FavState, Status>(
selector: (FavState state) => state.mergeStatus,
builder: (
BuildContext context,
Status status,
) {
return TextButton(
onPressed: () =>
context.read<FavCubit>().merge(
onError: (AppException e) =>
showErrorSnackBar(e.message),
onSuccess: () => showSnackBar(
content: '''Sync completed.''',
),
),
child: status == Status.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child:
CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text('Sync from Hacker News'),
);
},
)
: null;
if (favState.favItems.isEmpty &&
favState.status != Status.inProgress) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content:
'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
),
],
);
}
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled !=
current.isMetadataEnabled ||
previous.isUrlEnabled != current.isUrlEnabled,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return ItemsListView<Item>(
showWebPreviewOnStoryTile:
prefState.isComplexStoryTileEnabled,
showMetadataOnStoryTile:
prefState.isMetadataEnabled,
showFavicon: prefState.isFaviconEnabled,
showUrl: prefState.isUrlEnabled,
useSimpleTileForStory: true,
refreshController: refreshControllerFav,
items: favState.favItems,
onRefresh: () {
HapticFeedbackUtil.light();
context.read<FavCubit>().refresh();
},
onLoadMore: () {
context.read<FavCubit>().loadMore();
},
onTap: (Item item) => goToItemScreen(
args: ItemScreenArgs(item: item),
),
header: header(),
itemBuilder: (Widget child, Item item) {
return Slidable(
dragStartBehavior: DragStartBehavior.start,
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
context
.read<FavCubit>()
.removeFav(item.id);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: Icons.close,
),
],
),
child: child,
);
},
);
},
);
},
), ),
), ),
), ),

View File

@ -0,0 +1,187 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/profile/widgets/centered_message_view.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({
required this.refreshController,
required this.authState,
required this.onItemTap,
super.key,
});
final RefreshController refreshController;
final AuthState authState;
final void Function(Item) onItemTap;
@override
Widget build(BuildContext context) {
return BlocConsumer<FavCubit, FavState>(
listener: (BuildContext context, FavState favState) {
if (favState.status == Status.success) {
refreshController
..refreshCompleted()
..loadComplete();
}
},
buildWhen: (FavState previous, FavState current) =>
previous.favItems.length != current.favItems.length ||
previous.isDisplayingStories != current.isDisplayingStories,
builder: (BuildContext context, FavState favState) {
Widget? header() => Column(
children: <Widget>[
if (authState.isLoggedIn)
BlocSelector<FavCubit, FavState, Status>(
selector: (FavState state) => state.mergeStatus,
builder: (
BuildContext context,
Status status,
) {
return TextButton(
onPressed: () => context.read<FavCubit>().merge(
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
onSuccess: () => context.showSnackBar(
content: '''Sync completed.''',
),
),
child: status == Status.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'Sync from Hacker News',
),
);
},
),
Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt12,
),
CustomChip(
selected: favState.isDisplayingStories,
label: 'Story',
onSelected: (_) => context.read<FavCubit>().switchTab(),
),
const SizedBox(
width: Dimens.pt12,
),
CustomChip(
selected: !favState.isDisplayingStories,
label: 'Comment',
onSelected: (_) => context.read<FavCubit>().switchTab(),
),
],
),
],
);
if (favState.favItems.isEmpty && favState.status != Status.inProgress) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content: 'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
),
],
);
} else {
if (favState.isDisplayingStories && favState.favStories.isEmpty) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content: 'No favorite story.',
),
],
);
} else if (!favState.isDisplayingStories &&
favState.favComments.isEmpty) {
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content: 'No favorite comment.',
),
],
);
}
}
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.isComplexStoryTileEnabled !=
current.isComplexStoryTileEnabled ||
previous.isMetadataEnabled != current.isMetadataEnabled ||
previous.isUrlEnabled != current.isUrlEnabled,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return ItemsListView<Item>(
showWebPreviewOnStoryTile: prefState.isComplexStoryTileEnabled,
showMetadataOnStoryTile: prefState.isMetadataEnabled,
showFavicon: prefState.isFaviconEnabled,
showUrl: prefState.isUrlEnabled,
useSimpleTileForStory: true,
refreshController: refreshController,
items: favState.isDisplayingStories
? favState.favStories
: favState.favComments,
onRefresh: () {
HapticFeedbackUtil.light();
context.read<FavCubit>().refresh();
},
onLoadMore: () {
context.read<FavCubit>().loadMore();
},
onTap: onItemTap,
header: header(),
itemBuilder: (Widget child, Item item) {
return Slidable(
dragStartBehavior: DragStartBehavior.start,
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
context.read<FavCubit>().removeFav(item.id);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: Icons.close,
),
],
),
child: child,
);
},
);
},
);
},
);
}
}

View File

@ -606,7 +606,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin, Loggable {
context.pop(); context.pop();
locator locator
.get<SembastRepository>() .get<SembastRepository>()
.deleteAllCachedComments() .deleteAllCachedItems()
.whenComplete( .whenComplete(
locator.get<OfflineRepository>().deleteAll, locator.get<OfflineRepository>().deleteAll,
) )

View File

@ -1,5 +1,6 @@
export 'centered_message_view.dart'; export 'centered_message_view.dart';
export 'enter_offline_mode_list_tile.dart'; export 'enter_offline_mode_list_tile.dart';
export 'favorites_screen.dart';
export 'inbox_view.dart'; export 'inbox_view.dart';
export 'offline_list_tile.dart'; export 'offline_list_tile.dart';
export 'settings.dart'; export 'settings.dart';

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

@ -3,7 +3,18 @@ import 'package:hacki/models/models.dart' show Comment;
class CommentCache { class CommentCache {
static final Map<int, Comment> _comments = <int, Comment>{}; static final Map<int, Comment> _comments = <int, Comment>{};
void cacheComment(Comment comment) => _comments[comment.id] = comment; void cacheComment(Comment comment) {
_comments[comment.id] = comment;
/// Comments fetched from `HackerNewsWebRepository` doesn't have populated
/// `kids` field, this is why we need to update that of the parent
/// comment here.
final int parentId = comment.parent;
final Comment? parent = _comments[parentId];
if (parent == null || parent.kids.contains(comment.id)) return;
final Comment updatedParent = parent.copyWith(kid: comment.id);
_comments[parentId] = updatedParent;
}
Comment? getComment(int id) => _comments[id]; Comment? getComment(int id) => _comments[id];

View File

@ -13,6 +13,7 @@ abstract class Dimens {
static const double pt18 = 18; static const double pt18 = 18;
static const double pt20 = 20; static const double pt20 = 20;
static const double pt24 = 24; static const double pt24 = 24;
static const double pt30 = 30;
static const double pt36 = 36; static const double pt36 = 36;
static const double pt40 = 40; static const double pt40 = 40;
static const double pt48 = 48; static const double pt48 = 48;

View File

@ -250,10 +250,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: dio name: dio
sha256: "0dfb6b6a1979dac1c1245e17cef824d7b452ea29bd33d3467269f9bef3715fb0" sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.6.0" version: "5.7.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:
@ -1421,10 +1429,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.4" version: "14.2.5"
wakelock_plus: wakelock_plus:
dependency: "direct main" dependency: "direct main"
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.0" 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.0+148 version: 2.9.4+152
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.0" flutter: "3.24.3"
dependencies: dependencies:
adaptive_theme: ^3.2.0 adaptive_theme: ^3.2.0
@ -17,7 +17,8 @@ dependencies:
collection: ^1.17.1 collection: ^1.17.1
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.7.0
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: