Compare commits

..

1 Commits

Author SHA1 Message Date
19f2107d95 v0.2.28 (#65)
* bumped version.

* fixed comments cubit and story tile.

* cancel subscription on error.
2022-07-02 01:14:40 -07:00
13 changed files with 158 additions and 84 deletions

View File

@ -0,0 +1,3 @@
- Lazy loading.
- Offline mode now includes web pages.
- You can now sort comments in story screen.

View File

@ -568,7 +568,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7; DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -577,7 +577,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.27; MARKETING_VERSION = 0.2.28;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -705,7 +705,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7; DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -714,7 +714,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.27; MARKETING_VERSION = 0.2.28;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -736,7 +736,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7; DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -745,7 +745,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.2.27; MARKETING_VERSION = 0.2.28;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki; PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -11,6 +11,7 @@ import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.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:logger/logger.dart';
part 'comments_state.dart'; part 'comments_state.dart';
@ -21,6 +22,7 @@ class CommentsCubit extends Cubit<CommentsState> {
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
Logger? logger,
required bool offlineReading, required bool offlineReading,
required Item item, required Item item,
required FetchMode defaultFetchMode, required FetchMode defaultFetchMode,
@ -33,6 +35,7 @@ class CommentsCubit extends Cubit<CommentsState> {
storiesRepository ?? locator.get<StoriesRepository>(), storiesRepository ?? locator.get<StoriesRepository>(),
_sembastRepository = _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(),
super( super(
CommentsState.init( CommentsState.init(
offlineReading: offlineReading, offlineReading: offlineReading,
@ -47,8 +50,17 @@ class CommentsCubit extends Cubit<CommentsState> {
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
final Logger _logger;
/// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story.
StreamSubscription<Comment>? _streamSubscription; StreamSubscription<Comment>? _streamSubscription;
/// The map of [StreamSubscription] for streams
/// fetching comments lazily. [int] is the id of parent comment.
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _pageSize = 20; static const int _pageSize = 20;
@override @override
@ -73,7 +85,7 @@ class CommentsCubit extends Cubit<CommentsState> {
); );
_streamSubscription = _storiesRepository _streamSubscription = _storiesRepository
.fetchAllCommentsStream( .fetchAllCommentsRecursivelyStream(
ids: targetParents!.last.kids, ids: targetParents!.last.kids,
level: targetParents.last.level + 1, level: targetParents.last.level + 1,
) )
@ -105,22 +117,25 @@ class CommentsCubit extends Cubit<CommentsState> {
.listen(_onCommentFetched) .listen(_onCommentFetched)
..onDone(_onDone); ..onDone(_onDone);
} else { } else {
if (state.fetchMode == FetchMode.lazy) { switch (state.fetchMode) {
_streamSubscription = _storiesRepository case FetchMode.lazy:
.fetchCommentsStream( _streamSubscription = _storiesRepository
ids: kids, .fetchCommentsStream(
getFromCache: useCommentCache ? _commentCache.getComment : null, ids: kids,
) getFromCache: useCommentCache ? _commentCache.getComment : null,
.listen(_onCommentFetched) )
..onDone(_onDone); .listen(_onCommentFetched)
} else { ..onDone(_onDone);
_streamSubscription = _storiesRepository break;
.fetchAllCommentsStream( case FetchMode.eager:
ids: kids, _streamSubscription = _storiesRepository
getFromCache: useCommentCache ? _commentCache.getComment : null, .fetchAllCommentsRecursivelyStream(
) ids: kids,
.listen(_onCommentFetched) getFromCache: useCommentCache ? _commentCache.getComment : null,
..onDone(_onDone); )
.listen(_onCommentFetched)
..onDone(_onDone);
break;
} }
} }
} }
@ -135,18 +150,27 @@ class CommentsCubit extends Cubit<CommentsState> {
return; return;
} }
_collapseCache.resetCollapsedComments();
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.loading, status: CommentsStatus.loading,
),
);
_collapseCache.resetCollapsedComments();
await _streamSubscription?.cancel();
for (final int id in _streamSubscriptions.keys) {
await _streamSubscriptions[id]?.cancel();
}
_streamSubscriptions.clear();
emit(
state.copyWith(
comments: <Comment>[], comments: <Comment>[],
currentPage: 0, currentPage: 0,
), ),
); );
await _streamSubscription?.cancel();
final Item item = state.item; final Item item = state.item;
final Item updatedItem = final Item updatedItem =
await _storiesRepository.fetchItemBy(id: item.id) ?? item; await _storiesRepository.fetchItemBy(id: item.id) ?? item;
@ -161,7 +185,7 @@ class CommentsCubit extends Cubit<CommentsState> {
..onDone(_onDone); ..onDone(_onDone);
} else { } else {
_streamSubscription = _storiesRepository _streamSubscription = _storiesRepository
.fetchAllCommentsStream( .fetchAllCommentsRecursivelyStream(
ids: kids, ids: kids,
) )
.listen(_onCommentFetched) .listen(_onCommentFetched)
@ -189,20 +213,20 @@ class CommentsCubit extends Cubit<CommentsState> {
/// [comment] is only used for lazy fetching. /// [comment] is only used for lazy fetching.
void loadMore({Comment? comment}) { void loadMore({Comment? comment}) {
if (state.fetchMode == FetchMode.eager) { switch (state.fetchMode) {
if (_streamSubscription != null) { case FetchMode.lazy:
emit(state.copyWith(status: CommentsStatus.loading)); if (comment == null) return;
_streamSubscription?.resume(); if (_streamSubscriptions.containsKey(comment.id)) return;
}
} else {
if (comment == null) return;
final int level = comment.level + 1; final int level = comment.level + 1;
int offset = 0; int offset = 0;
_streamSubscription = _streamSubscription = /// Ignoring because the subscription will be cancelled in close()
_storiesRepository.fetchCommentsStream(ids: comment.kids).listen( // ignore: cancel_subscriptions
(Comment cmt) { final StreamSubscription<Comment> streamSubscription =
_storiesRepository
.fetchCommentsStream(ids: comment.kids)
.listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent); _collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt); _commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt); _sembastRepository.cacheComment(cmt);
@ -223,8 +247,25 @@ class CommentsCubit extends Cubit<CommentsState> {
), ),
); );
offset++; offset++;
}, })
); ..onDone(() {
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
})
..onError((dynamic error) {
_logger.e(error);
_streamSubscriptions[comment.id]?.cancel();
_streamSubscriptions.remove(comment.id);
});
_streamSubscriptions[comment.id] = streamSubscription;
break;
case FetchMode.eager:
if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading));
_streamSubscription?.resume();
}
break;
} }
} }
@ -255,6 +296,10 @@ class CommentsCubit extends Cubit<CommentsState> {
if (state.order == order) return; if (state.order == order) return;
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
_streamSubscription?.cancel(); _streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
s.cancel();
}
_streamSubscriptions.clear();
emit(state.copyWith(order: order)); emit(state.copyWith(order: order));
init(useCommentCache: true); init(useCommentCache: true);
} }
@ -265,6 +310,10 @@ class CommentsCubit extends Cubit<CommentsState> {
_collapseCache.resetCollapsedComments(); _collapseCache.resetCollapsedComments();
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
_streamSubscription?.cancel(); _streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
s.cancel();
}
_streamSubscriptions.clear();
emit(state.copyWith(fetchMode: fetchMode)); emit(state.copyWith(fetchMode: fetchMode));
init(useCommentCache: true); init(useCommentCache: true);
} }
@ -360,6 +409,9 @@ class CommentsCubit extends Cubit<CommentsState> {
@override @override
Future<void> close() async { Future<void> close() async {
await _streamSubscription?.cancel(); await _streamSubscription?.cancel();
for (final StreamSubscription<Comment> s in _streamSubscriptions.values) {
await s.cancel();
}
await super.close(); await super.close();
} }
} }

View File

@ -78,6 +78,8 @@ class CommentsState extends Equatable {
); );
} }
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
item, item,

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -16,4 +18,41 @@ extension TryReadContext on BuildContext {
box == null ? null : box.localToGlobal(Offset.zero) & box.size; box == null ? null : box.localToGlobal(Offset.zero) & box.size;
return rect; return rect;
} }
static double _screenWidth = 0;
static double _storyTileHeight = 0;
static int _storyTileMaxLines = 4;
static const double _screenWidthLowerBound = 428,
_screenWidthUpperBound = 850,
_picHeightLowerBound = 110,
_picHeightUpperBound = 128,
_smallPicHeight = 100,
_picHeightFactor = 0.3;
double get storyTileHeight {
final double screenWidth =
min(MediaQuery.of(this).size.height, MediaQuery.of(this).size.width);
if (screenWidth == _screenWidth) {
return _storyTileHeight;
} else {
_screenWidth = screenWidth;
}
final bool showSmallerPreviewPic = screenWidth > _screenWidthLowerBound &&
screenWidth < _screenWidthUpperBound;
final double height = showSmallerPreviewPic
? _smallPicHeight
: (screenWidth * _picHeightFactor)
.clamp(_picHeightLowerBound, _picHeightUpperBound);
final int maxLines = height == _smallPicHeight ? 3 : 4;
_storyTileMaxLines = maxLines;
_storyTileHeight = height;
return height;
}
int get storyTileMaxLines {
return _storyTileMaxLines;
}
} }

View File

@ -73,7 +73,7 @@ class StoriesRepository {
return; return;
} }
Stream<Comment> fetchAllCommentsStream({ Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids, required List<int> ids,
int level = 0, int level = 0,
Comment? Function(int)? getFromCache, Comment? Function(int)? getFromCache,
@ -94,7 +94,7 @@ class StoriesRepository {
if (comment != null) { if (comment != null) {
yield comment; yield comment;
yield* fetchAllCommentsStream( yield* fetchAllCommentsRecursivelyStream(
ids: comment.kids, ids: comment.kids,
level: level + 1, level: level + 1,
getFromCache: getFromCache, getFromCache: getFromCache,

View File

@ -523,7 +523,12 @@ class _ItemScreenState extends State<ItemScreen> {
strokeWidth: Dimens.pt2, strokeWidth: Dimens.pt2,
), ),
) )
: const Text('View parent thread'), : const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt12,
),
),
), ),
], ],
const Spacer(), const Spacer(),

View File

@ -526,7 +526,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: 'Hacki', applicationName: 'Hacki',
applicationVersion: 'v0.2.27', applicationVersion: 'v0.2.28',
applicationIcon: ClipRRect( applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all( borderRadius: const BorderRadius.all(
Radius.circular( Radius.circular(

View File

@ -288,9 +288,7 @@ class CommentTile extends StatelessWidget {
!context !context
.read<CommentsCubit>() .read<CommentsCubit>()
.state .state
.comments .commentIds
.map((Comment e) => e.id)
.toSet()
.contains(comment.kids.first)) .contains(comment.kids.first))
Center( Center(
child: TextButton( child: TextButton(

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/link_view.dart'; import 'package:hacki/screens/widgets/link_preview/link_view.dart';
import 'package:hacki/screens/widgets/link_preview/web_analyzer.dart'; import 'package:hacki/screens/widgets/link_preview/web_analyzer.dart';
@ -199,23 +200,9 @@ class _LinkPreviewState extends State<LinkPreview> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const double screenWidthLowerBound = 428,
screenWidthUpperBound = 850,
picHeightLowerBound = 118,
picHeightUpperBound = 140,
smallPicHeight = 100,
picHeightFactor = 0.14;
final double screenWidth = MediaQuery.of(context).size.width;
final bool showSmallerPreviewPic = screenWidth > screenWidthLowerBound &&
screenWidth < screenWidthUpperBound;
final double height = showSmallerPreviewPic
? smallPicHeight
: (MediaQuery.of(context).size.height * picHeightFactor)
.clamp(picHeightLowerBound, picHeightUpperBound);
final Widget loadingWidget = widget.placeholderWidget ?? final Widget loadingWidget = widget.placeholderWidget ??
Container( Container(
height: height, height: context.storyTileHeight,
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
@ -232,13 +219,13 @@ class _LinkPreviewState extends State<LinkPreview> {
final WebInfo? info = _info as WebInfo?; final WebInfo? info = _info as WebInfo?;
loadedWidget = _info == null loadedWidget = _info == null
? _buildLinkContainer( ? _buildLinkContainer(
height, context.storyTileHeight,
title: _errorTitle, title: _errorTitle,
desc: _errorBody, desc: _errorBody,
imageUri: null, imageUri: null,
) )
: _buildLinkContainer( : _buildLinkContainer(
height, context.storyTileHeight,
title: _errorTitle, title: _errorTitle,
desc: WebAnalyzer.isNotEmpty(info!.description) desc: WebAnalyzer.isNotEmpty(info!.description)
? info.description ? info.description

View File

@ -147,7 +147,7 @@ class LinkView extends StatelessWidget {
Widget _buildTitleContainer(TextStyle _titleTS, int _maxLines) { Widget _buildTitleContainer(TextStyle _titleTS, int _maxLines) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 3, 1), padding: const EdgeInsets.fromLTRB(4, 2, 3, 0),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
Container( Container(
@ -168,7 +168,7 @@ class LinkView extends StatelessWidget {
return Expanded( return Expanded(
flex: 2, flex: 2,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(5, 3, 5, 0), padding: const EdgeInsets.fromLTRB(5, 2, 5, 0),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
if (showMetadata) if (showMetadata)

View File

@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter_fadein/flutter_fadein.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';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
@ -29,20 +30,7 @@ class StoryTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (showWebPreview) { if (showWebPreview) {
const double screenWidthLowerBound = 428, final double height = context.storyTileHeight;
screenWidthUpperBound = 850,
picHeightLowerBound = 118,
picHeightUpperBound = 140,
smallPicHeight = 100,
picHeightFactor = 0.14;
final double screenWidth = MediaQuery.of(context).size.width;
final bool showSmallerPreviewPic = screenWidth > screenWidthLowerBound &&
screenWidth < screenWidthUpperBound;
final double height = showSmallerPreviewPic
? smallPicHeight
: (MediaQuery.of(context).size.height * picHeightFactor)
.clamp(picHeightLowerBound, picHeightUpperBound);
return TapDownWrapper( return TapDownWrapper(
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
@ -143,7 +131,7 @@ class StoryTile extends StatelessWidget {
backgroundColor: Palette.transparent, backgroundColor: Palette.transparent,
borderRadius: Dimens.zero, borderRadius: Dimens.zero,
removeElevation: true, removeElevation: true,
bodyMaxLines: height == smallPicHeight ? 3 : 4, bodyMaxLines: context.storyTileMaxLines,
errorTitle: story.title, errorTitle: story.title,
titleStyle: TextStyle( titleStyle: TextStyle(
color: hasRead color: hasRead

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 0.2.27+69 version: 0.2.28+70
publish_to: none publish_to: none
environment: environment: