Compare commits

..

3 Commits

Author SHA1 Message Date
c685f33f99 fix download progress bar. (#439) 2024-07-28 21:51:57 -07:00
518608893d auto scroll improvements. (#438) 2024-07-28 16:39:56 -07:00
856efa7c14 bump flutter version to 3.22.3. (#434) 2024-07-19 21:11:40 -07:00
47 changed files with 491 additions and 188 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,12 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,12 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@ -0,0 +1,7 @@
{
"athingComtrSelector": "#hnmain > tbody > tr > td > table > tbody > .athing.comtr",
"commentTextSelector": "td > table > tbody > tr > td.default > div.comment > div.commtext",
"commentHeadSelector": "td > table > tbody > tr > td.default > div > span > a",
"commentAgeSelector": "td > table > tbody > tr > td.default > div > span > span.age",
"commentIndentSelector": "td > table > tbody > tr > td.ind"
}

View File

@ -16,6 +16,8 @@ PODS:
- OrderedSet (~> 5.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_siri_suggestions (0.0.1):
@ -62,6 +64,7 @@ DEPENDENCIES:
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
@ -97,6 +100,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_siri_suggestions:
@ -137,6 +142,7 @@ SPEC CHECKSUMS:
flutter_email_sender: 10a22605f92809a11ef52b2f412db806c6082d40
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "darkbackground.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -1,23 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 69 B

View File

@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@ -33,5 +39,6 @@
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

@ -1,84 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>workmanager.background.task</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hacki</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>hacki</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>mailto</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan QR codes</string>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>workmanager.background.task</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hacki</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>hacki</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>mailto</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan QR codes</string>
<key>io.flutter.embedded_views_preview</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

View File

@ -69,6 +69,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
static const int _largePageSize = 20;
static const int _tabletSmallPageSize = 15;
static const int _tabletLargePageSize = 25;
static const String _logPrefix = '[StoriesBloc]';
Future<void> onInitialize(
StoriesInitialize event,
@ -245,7 +246,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final Story story = event.story;
if (state.storiesByType[event.type]?.contains(story) ?? false) {
_logger.d('story already exists.');
_logger.d(
'$_logPrefix story ${story.id} for ${event.type} already exists.',
);
return;
}
final bool hasRead = await _preferenceRepository.hasRead(story.id);
@ -349,20 +352,20 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
<StreamSubscription<Comment>>[];
for (final int id in ids) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading');
_logger.d('$_logPrefix aborting downloading');
for (final StreamSubscription<Comment> stream in downloadStreams) {
await stream.cancel();
}
_logger.d('deleting downloaded contents');
_logger.d('$_logPrefix deleting downloaded contents');
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
break;
}
_logger.d('fetching story $id');
_logger.d('$_logPrefix fetching story $id');
final Story? story = await _hackerNewsRepository.fetchStory(id: id);
if (story == null) {
@ -382,7 +385,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.cacheStory(story: story);
if (story.url.isNotEmpty && includingWebPage) {
_logger.i('downloading ${story.url}');
_logger.i('$_logPrefix downloading ${story.url}');
await _offlineRepository.cacheUrl(url: story.url);
}
@ -399,19 +402,19 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
.listen(
(Comment comment) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) {
_logger.d('aborting downloading from comments stream');
_logger.d('$_logPrefix aborting downloading from comments stream');
downloadStream?.cancel();
return;
}
_logger.d('fetched comment ${comment.id}');
_logger.d('$_logPrefix fetched comment ${comment.id}');
unawaited(
_offlineRepository.cacheComment(comment: comment),
);
},
)..onDone(() {
_logger.d(
'''finished downloading story ${story.id} with ${story.descendants} comments''',
'''$_logPrefix finished downloading story ${story.id} with ${story.descendants} comments''',
);
add(StoryDownloaded(skipped: false));
});

View File

@ -84,6 +84,7 @@ class CommentsCubit extends Cubit<CommentsState> {
<int, StreamSubscription<Comment>>{};
static const int _webFetchingCmtCountLowerLimit = 5;
static const String _logPrefix = '[CommentsCubit]';
Future<bool> get _shouldFetchFromWeb async {
final bool isOnWifi = await _isOnWifi;
@ -182,13 +183,14 @@ class CommentsCubit extends Cubit<CommentsState> {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
_logger
.d('$_logPrefix fetching comments of ${item.id} from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_streamSubscription?.cancel();
_logger.e(e);
_logger.e('$_logPrefix $e');
switch (e.runtimeType) {
case RateLimitedException:
@ -205,7 +207,8 @@ class CommentsCubit extends Cubit<CommentsState> {
}
});
} else {
_logger.d('fetching from API.');
_logger
.d('$_logPrefix fetching comments of ${item.id} from API.');
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
@ -280,11 +283,13 @@ class CommentsCubit extends Cubit<CommentsState> {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
_logger.d(
'$_logPrefix fetching comments of ${item.id} from web.',
);
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_logger.e(e);
_logger.e('$_logPrefix $e');
switch (e.runtimeType) {
case RateLimitedException:
@ -301,7 +306,8 @@ class CommentsCubit extends Cubit<CommentsState> {
}
});
} else {
_logger.d('fetching from API.');
_logger
.d('$_logPrefix fetching comments of ${item.id} from API.');
commentStream = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream(ids: kids);
}

View File

@ -67,6 +67,7 @@ class NotificationCubit extends Cubit<NotificationState> {
static const Duration _refreshInterval = Duration(minutes: 5);
static const int _subscriptionUpperLimit = 15;
static const int _pageSize = 20;
static const String _logPrefix = '[NotificationCubit]';
Future<void> init() async {
emit(NotificationState.init());
@ -78,7 +79,7 @@ class NotificationCubit extends Cubit<NotificationState> {
});
await _preferenceRepository.unreadCommentsIds.then((List<int> unreadIds) {
_logger.i('NotificationCubit: ${unreadIds.length} unread items.');
_logger.i('$_logPrefix ${unreadIds.length} unread items.');
emit(state.copyWith(unreadCommentsIds: unreadIds));
});
@ -104,31 +105,17 @@ class NotificationCubit extends Cubit<NotificationState> {
}
void markAsRead(int id) {
Future.doWhile(() {
if (state.status != Status.inProgress) {
if (state.unreadCommentsIds.contains(id)) {
final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds]
..remove(id);
_preferenceRepository.updateUnreadCommentsIds(updatedUnreadIds);
emit(state.copyWith(unreadCommentsIds: updatedUnreadIds));
}
return false;
}
return true;
});
if (state.unreadCommentsIds.contains(id)) {
final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds]
..remove(id);
_preferenceRepository.updateUnreadCommentsIds(updatedUnreadIds);
emit(state.copyWith(unreadCommentsIds: updatedUnreadIds));
}
}
void markAllAsRead() {
Future.doWhile(() {
if (state.status != Status.inProgress) {
emit(state.copyWith(unreadCommentsIds: <int>[]));
_preferenceRepository.updateUnreadCommentsIds(<int>[]);
return false;
}
return true;
});
emit(state.copyWith(unreadCommentsIds: <int>[]));
_preferenceRepository.updateUnreadCommentsIds(<int>[]);
}
Future<void> refresh() async {

View File

@ -23,6 +23,7 @@ class PreferenceCubit extends Cubit<PreferenceState> {
final PreferenceRepository _preferenceRepository;
final Logger _logger;
static const String _logPrefix = '[PreferenceCubit]';
void init() {
for (final BooleanPreference p
@ -73,7 +74,7 @@ class PreferenceCubit extends Cubit<PreferenceState> {
}
void update<T>(Preference<T> preference) {
_logger.i('updating $preference to ${preference.val}');
_logger.i('$_logPrefix updating $preference to ${preference.val}');
emit(state.copyWithPreference(preference));

View File

@ -3,25 +3,34 @@ import 'package:flutter/cupertino.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/repositories/remote_config_repository.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:logger/logger.dart';
part 'remote_config_state.dart';
class RemoteConfigCubit extends HydratedCubit<RemoteConfigState> {
RemoteConfigCubit({RemoteConfigRepository? remoteConfigRepository})
: _remoteConfigRepository =
RemoteConfigCubit({
RemoteConfigRepository? remoteConfigRepository,
Logger? logger,
}) : _remoteConfigRepository =
remoteConfigRepository ?? locator.get<RemoteConfigRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(RemoteConfigState.init()) {
init();
}
final RemoteConfigRepository _remoteConfigRepository;
final Logger _logger;
static const String _logPrefix = '';
void init() {
_remoteConfigRepository
.fetchRemoteConfig()
.then((Map<String, dynamic> data) {
if (data.isNotEmpty) {
_logger.i('$_logPrefix remote config fetched: $data');
emit(state.copyWith(data: data));
} else {
_logger.i('$_logPrefix empty remote config.');
}
});
}

View File

@ -17,9 +17,10 @@ class SplitViewCubit extends Cubit<SplitViewState> {
final Logger _logger;
final CommentCache _commentCache;
static const String _logPrefix = '[SplitViewCubit]';
void updateItemScreenArgs(ItemScreenArgs args) {
_logger.i('resetting comments in CommentCache');
_logger.i('$_logPrefix resetting comments in CommentCache');
_commentCache.resetComments();
emit(state.copyWith(itemScreenArgs: args));
}

View File

@ -19,17 +19,20 @@ class TabCubit extends Cubit<TabState> {
final PreferenceCubit _preferenceCubit;
final Logger _logger;
static const String _logPrefix = '[TabCubit]';
void init() {
final List<StoryType> tabs = _preferenceCubit.state.tabs;
_logger.i('updating tabs to $tabs');
_logger.i('$_logPrefix updating tabs to $tabs');
emit(state.copyWith(tabs: tabs));
}
void update(int startIndex, int endIndex) {
_logger.d('updating ${state.tabs} by moving $startIndex to $endIndex');
_logger.d(
'$_logPrefix updating ${state.tabs} by moving $startIndex to $endIndex',
);
final StoryType tab = state.tabs.elementAt(startIndex);
final List<StoryType> updatedTabs = List<StoryType>.from(state.tabs)
..insert(endIndex, tab)

View File

@ -164,7 +164,7 @@ final class AutoScrollModePreference extends BooleanPreference {
const AutoScrollModePreference({bool? val})
: super(val: val ?? _autoScrollModeDefaultValue);
static const bool _autoScrollModeDefaultValue = false;
static const bool _autoScrollModeDefaultValue = true;
@override
AutoScrollModePreference copyWith({required bool? val}) {

View File

@ -1,15 +1,20 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
class RemoteConfigRepository {
RemoteConfigRepository({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
static const String _path =
'https://raw.githubusercontent.com/Livinglist/Hacki/master/assets/';
Future<Map<String, dynamic>> fetchRemoteConfig() async {
const String fileName =
kReleaseMode ? 'remote-config.json' : 'remote-config-dev.json';
final Response<dynamic> response = await _dio.get(
'https://raw.githubusercontent.com/Livinglist/Hacki/master/assets/remote-config.json',
'$_path$fileName',
);
final String data = response.data as String? ?? '';
final Map<String, dynamic> json = jsonDecode(data) as Map<String, dynamic>;

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:logger/logger.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart';
@ -17,7 +19,8 @@ class SembastRepository {
SembastRepository({
Database? database,
Database? cache,
}) {
Logger? logger,
}) : _logger = logger ?? locator.get<Logger>() {
if (database == null) {
initializeDatabase();
} else {
@ -31,6 +34,9 @@ class SembastRepository {
}
}
final Logger _logger;
static const String _logPrefix = '[SembastRepository]';
Database? _database;
Database? _cache;
List<int>? _idsOfCommentsRepliedToMe;
@ -44,6 +50,9 @@ class SembastRepository {
final Directory dir = await getApplicationCacheDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db');
final File file = File(dbPath);
final FileStat stat = file.statSync();
_logger.i('$_logPrefix hacki.db file size: ${stat.size / 1000000}MB');
final DatabaseFactory dbFactory = databaseFactoryIo;
final Database db = await dbFactory.openDatabase(dbPath);
_database = db;
@ -54,6 +63,9 @@ class SembastRepository {
final Directory tempDir = await getTemporaryDirectory();
await tempDir.create(recursive: true);
final String dbPath = join(tempDir.path, 'hacki_cache.db');
final File file = File(dbPath);
final FileStat stat = file.statSync();
_logger.i('$_logPrefix hacki_cache.db file size: ${stat.size / 1000000}MB');
final DatabaseFactory dbFactory = databaseFactoryIo;
final Database db = await dbFactory.openDatabase(dbPath);
_cache = db;

View File

@ -43,13 +43,14 @@ class _HomeScreenState extends State<HomeScreen>
late final StreamSubscription<String?> siriSuggestionStreamSubscription;
static final int tabLength = StoryType.values.length + 1;
static const String logPrefix = '[HomeScreen]';
@override
void didPopNext() {
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
locator.get<Logger>().i('$logPrefix resetting comments in CommentCache');
Future<void>.delayed(
AppDurations.ms500,
locator.get<CommentCache>().resetComments,

View File

@ -24,6 +24,14 @@ class MobileHomeScreen extends StatelessWidget {
bottom: Dimens.pt36,
height: Dimens.pt40,
child: CountdownReminder(),
)
else
const Positioned(
left: Dimens.pt24,
right: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
child: DownloadProgressReminder(),
),
],
);

View File

@ -41,13 +41,22 @@ class TabletHomeScreen extends StatelessWidget {
curve: Curves.elasticOut,
child: homeScreen,
),
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const CountdownReminder(),
),
if (!context.read<ReminderCubit>().state.hasShown)
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const CountdownReminder(),
)
else
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const DownloadProgressReminder(),
),
AnimatedPositioned(
right: Dimens.zero,
top: Dimens.zero,

View File

@ -32,7 +32,9 @@ class InboxView extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: <Widget>[
if (unreadCommentsIds.isNotEmpty)
if (context.read<NotificationCubit>().state.status !=
Status.inProgress &&
unreadCommentsIds.isNotEmpty)
TextButton(
onPressed: onMarkAllAsReadTapped,
child: const Text('Mark all as read'),

View File

@ -341,6 +341,13 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
),
onTap: showClearCacheDialog,
),
if (preferenceState.isDevModeEnabled)
ListTile(
title: const Text(
'Logs',
),
onTap: () {},
),
ListTile(
title: const Text('About'),
subtitle: const Text('nothing interesting here.'),

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@ -10,6 +11,7 @@ import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CommentTile extends StatelessWidget {
const CommentTile({
@ -417,18 +419,28 @@ class CommentTile extends StatelessWidget {
preferenceCubit.state.isAutoScrollEnabled) {
final CommentsCubit commentsCubit = context.read<CommentsCubit>();
final List<Comment> comments = commentsCubit.state.comments;
final int indexOfNextComment = comments.indexOf(comment) + 1;
if (indexOfNextComment < comments.length) {
Future<void>.delayed(
AppDurations.ms300,
() {
commentsCubit.itemScrollController.scrollTo(
index: indexOfNextComment,
alignment: 0.1,
duration: AppDurations.ms300,
);
},
);
final int indexOfComment = comments.indexOf(comment);
if (indexOfComment < comments.length) {
final double? leadingEdge =
commentsCubit.itemPositionsListener.itemPositions.value
.singleWhereOrNull(
(ItemPosition e) => e.index - 1 == indexOfComment,
)
?.itemLeadingEdge;
final bool willBeOutsideOfScreen =
leadingEdge != null && leadingEdge < 0.1;
if (willBeOutsideOfScreen) {
Future<void>.delayed(
AppDurations.ms200,
() {
commentsCubit.itemScrollController.scrollTo(
index: indexOfComment + 1,
alignment: 0.15,
duration: AppDurations.ms300,
);
},
);
}
}
}
}

View File

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/styles/styles.dart';
class DownloadProgressReminder extends StatefulWidget {
const DownloadProgressReminder({super.key});
@override
State<DownloadProgressReminder> createState() =>
_DownloadProgressReminderState();
}
class _DownloadProgressReminderState extends State<DownloadProgressReminder>
with SingleTickerProviderStateMixin, ItemActionMixin {
@override
Widget build(BuildContext context) {
return BlocSelector<StoriesBloc, StoriesState,
(int, int, StoriesDownloadStatus)>(
selector: (StoriesState state) {
return (
state.storiesDownloaded,
state.storiesToBeDownloaded,
state.downloadStatus
);
},
builder: (BuildContext context, (int, int, StoriesDownloadStatus) state) {
final int storiesDownloaded = state.$1;
final int storiesToBeDownloaded = state.$2;
final StoriesDownloadStatus status = state.$3;
final double progress = storiesToBeDownloaded == 0
? 0
: storiesDownloaded / storiesToBeDownloaded;
final bool isVisible = status == StoriesDownloadStatus.downloading;
return Visibility(
visible: isVisible,
child: FadeIn(
child: Material(
color: Theme.of(context).colorScheme.primary,
clipBehavior: Clip.hardEdge,
borderRadius: const BorderRadius.all(
Radius.circular(
Dimens.pt4,
),
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt12,
top: Dimens.pt10,
right: Dimens.pt10,
),
child: Row(
children: <Widget>[
Text(
'Downloading all stories ($storiesDownloaded/$storiesToBeDownloaded)',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: TextDimens.pt12,
),
),
const Spacer(),
],
),
),
const Spacer(),
LinearProgressIndicator(
value: progress,
color:
Theme.of(context).colorScheme.primary.withOpacity(0.5),
),
],
),
),
),
);
},
);
}
}

View File

@ -10,6 +10,7 @@ export 'custom_dropdown_menu.dart';
export 'custom_linkify/custom_linkify.dart';
export 'custom_tab_bar.dart';
export 'device_gesture_wrapper.dart';
export 'download_progress_reminder.dart';
export 'item_text.dart';
export 'items_list_view.dart';
export 'link_preview/link_preview.dart';

View File

@ -38,15 +38,15 @@ abstract class Fetcher {
final Logger logger = Logger();
final PreferenceRepository preferenceRepository =
PreferenceRepository(logger: logger);
final AuthRepository authRepository = AuthRepository(
preferenceRepository: preferenceRepository,
logger: logger,
);
final HackerNewsRepository hackerNewsRepository = HackerNewsRepository();
final SembastRepository sembastRepository = SembastRepository();
final HackerNewsRepository hackerNewsRepository = HackerNewsRepository(
sembastRepository: sembastRepository,
logger: logger,
);
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();

View File

@ -85,6 +85,7 @@ class WebAnalyzer {
RegExp('(title|icon|description|image)', caseSensitive: false);
static final RegExp _lineReg = RegExp(r'[\n\r]|&nbsp;|&gt;');
static final RegExp _spaceReg = RegExp(r'\s+');
static const String _logPrefix = '[WebAnalyzer]';
static bool isEmpty(String? str) {
return !isNotEmpty(str);
@ -120,7 +121,7 @@ class WebAnalyzer {
if (info != null) {
locator.get<Logger>().d('''
fetched mem cached metadata using key $key for $story:
$_logPrefix fetched mem cached metadata using key $key for $story:
${info.toJson()}
''');
return info;
@ -168,7 +169,7 @@ ${info.toJson()}
/// [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:
$_logPrefix fetched file cached metadata using key $key for $story:
${info.toJson()}
''');
cacheMap[key] = info;
@ -189,7 +190,7 @@ ${info.toJson()}
if (info is WebInfo) {
locator
.get<Logger>()
.d('caching metadata using key $key for $story.');
.d('$_logPrefix caching metadata using key $key for $story.');
unawaited(
locator.get<SembastRepository>().cacheMetadata(
key: key,

View File

@ -8,10 +8,12 @@ import 'package:path_provider/path_provider.dart';
abstract class LogUtil {
static LogPrinter get logPrinter => kReleaseMode
? SimplePrinter(colors: false)
: PrettyPrinter(
methodCount: 0,
? SimplePrinter(
colors: false,
printTime: true,
)
: PrettyPrinter(
printTime: true,
);
static LogOutput logOutput(File outputFile) => MultiOutput(

View File

@ -33,6 +33,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.11"
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
@ -436,6 +452,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
flutter_native_splash:
dependency: "direct main"
description:
name: flutter_native_splash
sha256: aa06fec78de2190f3db4319dd60fdc8d12b2626e93ef9828633928c2dcaea840
url: "https://pub.dev"
source: hosted
version: "2.4.1"
flutter_secure_storage:
dependency: "direct main"
description:
@ -612,6 +636,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.1.5"
image:
dependency: transitive
description:
name: image
sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
in_app_review:
dependency: "direct main"
description:
@ -1269,6 +1301,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.2"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
universal_platform:
dependency: "direct main"
description:
@ -1543,4 +1583,4 @@ packages:
version: "3.1.2"
sdks:
dart: ">=3.4.0 <4.0.0"
flutter: ">=3.22.2"
flutter: ">=3.22.3"

View File

@ -1,11 +1,11 @@
name: hacki
description: A Hacker News reader.
version: 2.8.1+146
version: 2.8.2+147
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: "3.22.2"
flutter: "3.22.3"
dependencies:
adaptive_theme: ^3.2.0
@ -35,6 +35,7 @@ dependencies:
flutter_inappwebview: ^6.0.0
flutter_local_notifications: ^17.1.2
flutter_material_color_picker: ^1.2.0
flutter_native_splash: ^2.4.1
flutter_secure_storage: ^9.2.2
flutter_siri_suggestions:
git:
@ -139,4 +140,23 @@ flutter:
- asset: assets/fonts/atkinson_hyperlegible/AtkinsonHyperlegible-Bold.ttf
weight: 700
flutter_native_splash:
# This package generates native code to customize Flutter's default white native splash screen
# with background color and splash image.
# Customize the parameters below, and run the following command in the terminal:
# dart run flutter_native_splash:create
# To restore Flutter's default white splash screen, run the following command in the terminal:
# dart run flutter_native_splash:remove
# IMPORTANT NOTE: These parameter do not affect the configuration of Android 12 and later, which
# handle splash screens differently that prior versions of Android. Android 12 and later must be
# configured specifically in the android_12 section below.
# color or background_image is the only required parameter. Use color to set the background
# of your splash screen to a solid color. Use background_image to set the background of your
# splash screen to a png image. This is useful for gradients. The image will be stretch to the
# size of the app. Only one parameter can be used, color and background_image cannot both be set.
color: "#ffffff"
color_dark: "#000000"