mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
9312c56dd0 | |||
6e71de5913 |
@ -4,7 +4,6 @@
|
|||||||
A simple noiseless [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
|
A simple noiseless [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
|
||||||
|
|
||||||
[](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
|
[](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
|
||||||
[](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US)
|
|
||||||
[](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
|
[](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
|
||||||
[](https://github.com/Livinglist/Hacki/releases/latest)
|
[](https://github.com/Livinglist/Hacki/releases/latest)
|
||||||
[](https://badges.pufler.dev)
|
[](https://badges.pufler.dev)
|
||||||
|
6
fastlane/metadata/android/en-US/changelogs/56.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/56.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
- You can now add filters for searching.
|
||||||
|
- You can now participate in polls.
|
||||||
|
- Pick up where you left off.
|
||||||
|
- Swipe left on comment tile to view its parents without scrolling all the way up.
|
||||||
|
- Huge performance boost.
|
||||||
|
- Bugfixes.
|
@ -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 = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
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.16;
|
MARKETING_VERSION = 0.2.17;
|
||||||
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 = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
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.16;
|
MARKETING_VERSION = 0.2.17;
|
||||||
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 = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
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.16;
|
MARKETING_VERSION = 0.2.17;
|
||||||
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 = "";
|
||||||
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||||
import 'package:hacki/config/locator.dart';
|
import 'package:hacki/config/locator.dart';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
import 'package:hacki/repositories/repositories.dart';
|
import 'package:hacki/repositories/repositories.dart';
|
||||||
@ -159,10 +160,19 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
..addKid(comment.id, to: comment.parent)
|
..addKid(comment.id, to: comment.parent)
|
||||||
..cacheComment(comment);
|
..cacheComment(comment);
|
||||||
_sembastRepository.cacheComment(comment);
|
_sembastRepository.cacheComment(comment);
|
||||||
|
|
||||||
|
final List<LinkifyElement> elements = linkify(
|
||||||
|
comment.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
final BuildableComment buildableComment =
|
||||||
|
BuildableComment.fromComment(comment, elements: elements);
|
||||||
|
|
||||||
final List<Comment> updatedComments = <Comment>[
|
final List<Comment> updatedComments = <Comment>[
|
||||||
...state.comments,
|
...state.comments,
|
||||||
comment
|
buildableComment
|
||||||
];
|
];
|
||||||
|
|
||||||
emit(state.copyWith(comments: updatedComments));
|
emit(state.copyWith(comments: updatedComments));
|
||||||
|
|
||||||
if (updatedComments.length >= _pageSize + _pageSize * state.currentPage &&
|
if (updatedComments.length >= _pageSize + _pageSize * state.currentPage &&
|
||||||
@ -180,6 +190,31 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<LinkifyElement> linkify(
|
||||||
|
String text, {
|
||||||
|
LinkifyOptions options = const LinkifyOptions(),
|
||||||
|
List<Linkifier> linkifiers = const <Linkifier>[
|
||||||
|
UrlLinkifier(),
|
||||||
|
EmailLinkifier(),
|
||||||
|
],
|
||||||
|
}) {
|
||||||
|
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
|
||||||
|
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return <LinkifyElement>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkifiers.isEmpty) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final Linkifier linkifier in linkifiers) {
|
||||||
|
list = linkifier.parse(list, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
await _streamSubscription?.cancel();
|
await _streamSubscription?.cancel();
|
||||||
|
@ -14,4 +14,5 @@ export 'search/search_cubit.dart';
|
|||||||
export 'split_view/split_view_cubit.dart';
|
export 'split_view/split_view_cubit.dart';
|
||||||
export 'submit/submit_cubit.dart';
|
export 'submit/submit_cubit.dart';
|
||||||
export 'time_machine/time_machine_cubit.dart';
|
export 'time_machine/time_machine_cubit.dart';
|
||||||
|
export 'user/user_cubit.dart';
|
||||||
export 'vote/vote_cubit.dart';
|
export 'vote/vote_cubit.dart';
|
||||||
|
26
lib/cubits/user/user_cubit.dart
Normal file
26
lib/cubits/user/user_cubit.dart
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:hacki/config/locator.dart';
|
||||||
|
import 'package:hacki/models/models.dart';
|
||||||
|
import 'package:hacki/repositories/repositories.dart';
|
||||||
|
|
||||||
|
part 'user_state.dart';
|
||||||
|
|
||||||
|
class UserCubit extends Cubit<UserState> {
|
||||||
|
UserCubit({StoriesRepository? storiesRepository})
|
||||||
|
: _storiesRepository =
|
||||||
|
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||||
|
super(UserState.init());
|
||||||
|
|
||||||
|
final StoriesRepository _storiesRepository;
|
||||||
|
|
||||||
|
void init({required String userId}) {
|
||||||
|
emit(state.copyWith(status: UserStatus.loading));
|
||||||
|
_storiesRepository.fetchUserBy(userId: userId).then((User user) {
|
||||||
|
emit(state.copyWith(user: user, status: UserStatus.loaded));
|
||||||
|
}).onError((_, __) {
|
||||||
|
emit(state.copyWith(status: UserStatus.failure));
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
38
lib/cubits/user/user_state.dart
Normal file
38
lib/cubits/user/user_state.dart
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
part of 'user_cubit.dart';
|
||||||
|
|
||||||
|
enum UserStatus {
|
||||||
|
initial,
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
failure,
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserState extends Equatable {
|
||||||
|
const UserState({
|
||||||
|
required this.user,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
UserState.init()
|
||||||
|
: user = User.empty(),
|
||||||
|
status = UserStatus.initial;
|
||||||
|
|
||||||
|
final User user;
|
||||||
|
final UserStatus status;
|
||||||
|
|
||||||
|
UserState copyWith({
|
||||||
|
User? user,
|
||||||
|
UserStatus? status,
|
||||||
|
}) {
|
||||||
|
return UserState(
|
||||||
|
user: user ?? this.user,
|
||||||
|
status: status ?? this.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[
|
||||||
|
user,
|
||||||
|
status,
|
||||||
|
];
|
||||||
|
}
|
33
lib/models/buildable_comment.dart
Normal file
33
lib/models/buildable_comment.dart
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||||
|
import 'package:hacki/models/comment.dart';
|
||||||
|
import 'package:hacki/models/models.dart';
|
||||||
|
|
||||||
|
class BuildableComment extends Comment {
|
||||||
|
BuildableComment({
|
||||||
|
required super.id,
|
||||||
|
required super.time,
|
||||||
|
required super.parent,
|
||||||
|
required super.score,
|
||||||
|
required super.by,
|
||||||
|
required super.text,
|
||||||
|
required super.kids,
|
||||||
|
required super.deleted,
|
||||||
|
required super.level,
|
||||||
|
required this.elements,
|
||||||
|
});
|
||||||
|
|
||||||
|
BuildableComment.fromComment(Comment comment, {required this.elements})
|
||||||
|
: super(
|
||||||
|
id: comment.id,
|
||||||
|
time: comment.time,
|
||||||
|
parent: comment.parent,
|
||||||
|
score: comment.score,
|
||||||
|
by: comment.by,
|
||||||
|
text: comment.text,
|
||||||
|
kids: comment.kids,
|
||||||
|
deleted: comment.deleted,
|
||||||
|
level: comment.level,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<LinkifyElement> elements;
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
export 'buildable_comment.dart';
|
||||||
export 'comment.dart';
|
export 'comment.dart';
|
||||||
export 'item.dart';
|
export 'item.dart';
|
||||||
export 'poll_option.dart';
|
export 'poll_option.dart';
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class User {
|
class User {
|
||||||
User({
|
User({
|
||||||
required this.about,
|
required this.about,
|
||||||
@ -29,6 +31,12 @@ class User {
|
|||||||
final String id;
|
final String id;
|
||||||
final int karma;
|
final int karma;
|
||||||
|
|
||||||
|
static final DateFormat _dateTimeFormatter = DateFormat.yMMMd();
|
||||||
|
|
||||||
|
String get description {
|
||||||
|
return '''$karma karma, created on ${_dateTimeFormatter.format(DateTime.fromMillisecondsSinceEpoch(created * 1000))}''';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
final String prettyString =
|
final String prettyString =
|
||||||
|
@ -171,7 +171,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.orangeAccent.withOpacity(0.2),
|
color: Colors.orangeAccent.withOpacity(0.2),
|
||||||
child: StoryTile(
|
child: StoryTile(
|
||||||
key: ObjectKey(story),
|
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
|
||||||
story: story,
|
story: story,
|
||||||
onTap: () => onStoryTapped(story, isPin: true),
|
onTap: () => onStoryTapped(story, isPin: true),
|
||||||
showWebPreview: preferenceState.showComplexStoryTile,
|
showWebPreview: preferenceState.showComplexStoryTile,
|
||||||
@ -543,7 +543,7 @@ class _TabletStoryView extends StatelessWidget {
|
|||||||
previous.storyScreenArgs != current.storyScreenArgs,
|
previous.storyScreenArgs != current.storyScreenArgs,
|
||||||
builder: (BuildContext context, SplitViewState state) {
|
builder: (BuildContext context, SplitViewState state) {
|
||||||
if (state.storyScreenArgs != null) {
|
if (state.storyScreenArgs != null) {
|
||||||
return StoryScreen.build(state.storyScreenArgs!);
|
return StoryScreen.build(context, state.storyScreenArgs!);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
|
@ -407,7 +407,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
showAboutDialog(
|
showAboutDialog(
|
||||||
context: context,
|
context: context,
|
||||||
applicationName: 'Hacki',
|
applicationName: 'Hacki',
|
||||||
applicationVersion: 'v0.2.16',
|
applicationVersion: 'v0.2.17',
|
||||||
applicationIcon: ClipRRect(
|
applicationIcon: ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(
|
borderRadius: const BorderRadius.all(
|
||||||
Radius.circular(12),
|
Radius.circular(12),
|
||||||
|
@ -88,29 +88,39 @@ class StoryScreen extends StatefulWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget build(StoryScreenArgs args) {
|
static Widget build(BuildContext context, StoryScreenArgs args) {
|
||||||
return MultiBlocProvider(
|
return WillPopScope(
|
||||||
key: ValueKey<StoryScreenArgs>(args),
|
onWillPop: () async {
|
||||||
providers: <BlocProvider<dynamic>>[
|
if (context.read<SplitViewCubit>().state.expanded) {
|
||||||
BlocProvider<CommentsCubit>(
|
context.read<SplitViewCubit>().zoom();
|
||||||
create: (BuildContext context) => CommentsCubit(
|
return false;
|
||||||
offlineReading: context.read<StoriesBloc>().state.offlineReading,
|
} else {
|
||||||
story: args.story,
|
return true;
|
||||||
)..init(
|
}
|
||||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
},
|
||||||
targetParents: args.targetComments,
|
child: MultiBlocProvider(
|
||||||
),
|
key: ValueKey<StoryScreenArgs>(args),
|
||||||
),
|
providers: <BlocProvider<dynamic>>[
|
||||||
if (args.story.isPoll)
|
BlocProvider<CommentsCubit>(
|
||||||
BlocProvider<PollCubit>(
|
create: (BuildContext context) => CommentsCubit(
|
||||||
create: (BuildContext context) =>
|
offlineReading: context.read<StoriesBloc>().state.offlineReading,
|
||||||
PollCubit(story: args.story)..init(),
|
story: args.story,
|
||||||
|
)..init(
|
||||||
|
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||||
|
targetParents: args.targetComments,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
if (args.story.isPoll)
|
||||||
child: StoryScreen(
|
BlocProvider<PollCubit>(
|
||||||
story: args.story,
|
create: (BuildContext context) =>
|
||||||
parentComments: args.targetComments ?? <Comment>[],
|
PollCubit(story: args.story)..init(),
|
||||||
splitViewEnabled: true,
|
),
|
||||||
|
],
|
||||||
|
child: StoryScreen(
|
||||||
|
story: args.story,
|
||||||
|
parentComments: args.targetComments ?? <Comment>[],
|
||||||
|
splitViewEnabled: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -140,7 +150,6 @@ class _StoryScreenState extends State<StoryScreen> {
|
|||||||
delay: _featureDiscoveryDismissThrottleDelay,
|
delay: _featureDiscoveryDismissThrottleDelay,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const int _extraItemsCount = 2;
|
|
||||||
static const Duration _storyLinkTapThrottleDelay = Duration(seconds: 2);
|
static const Duration _storyLinkTapThrottleDelay = Duration(seconds: 2);
|
||||||
static const Duration _featureDiscoveryDismissThrottleDelay =
|
static const Duration _featureDiscoveryDismissThrottleDelay =
|
||||||
Duration(seconds: 1);
|
Duration(seconds: 1);
|
||||||
@ -270,229 +279,210 @@ class _StoryScreenState extends State<StoryScreen> {
|
|||||||
onLoading: () {
|
onLoading: () {
|
||||||
context.read<CommentsCubit>().loadMore();
|
context.read<CommentsCubit>().loadMore();
|
||||||
},
|
},
|
||||||
child: ListView.builder(
|
child: ListView(
|
||||||
primary: false,
|
primary: false,
|
||||||
itemCount: state.comments.length + _extraItemsCount,
|
children: <Widget>[
|
||||||
itemBuilder: (BuildContext context, int index) {
|
SizedBox(
|
||||||
if (index == 0) {
|
height: topPadding,
|
||||||
return Column(
|
),
|
||||||
|
if (!widget.splitViewEnabled)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 6),
|
||||||
|
child: OfflineBanner(),
|
||||||
|
),
|
||||||
|
Slidable(
|
||||||
|
startActionPane: ActionPane(
|
||||||
|
motion: const BehindMotion(),
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
SizedBox(
|
SlidableAction(
|
||||||
height: topPadding,
|
onPressed: (_) {
|
||||||
),
|
HapticFeedback.lightImpact();
|
||||||
if (!widget.splitViewEnabled)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(bottom: 6),
|
|
||||||
child: OfflineBanner(),
|
|
||||||
),
|
|
||||||
Slidable(
|
|
||||||
startActionPane: ActionPane(
|
|
||||||
motion: const BehindMotion(),
|
|
||||||
children: <Widget>[
|
|
||||||
SlidableAction(
|
|
||||||
onPressed: (_) {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
|
|
||||||
if (widget.story !=
|
if (widget.story !=
|
||||||
context
|
context.read<EditCubit>().state.replyingTo) {
|
||||||
.read<EditCubit>()
|
commentEditingController.clear();
|
||||||
.state
|
}
|
||||||
.replyingTo) {
|
context
|
||||||
commentEditingController.clear();
|
.read<EditCubit>()
|
||||||
}
|
.onReplyTapped(widget.story);
|
||||||
context
|
focusNode.requestFocus();
|
||||||
.read<EditCubit>()
|
},
|
||||||
.onReplyTapped(widget.story);
|
backgroundColor: Colors.orange,
|
||||||
focusNode.requestFocus();
|
foregroundColor: Colors.white,
|
||||||
},
|
icon: Icons.message,
|
||||||
backgroundColor: Colors.orange,
|
),
|
||||||
foregroundColor: Colors.white,
|
SlidableAction(
|
||||||
icon: Icons.message,
|
onPressed: (_) => onMorePressed(widget.story),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
icon: Icons.more_horiz,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 6,
|
||||||
|
right: 6,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
widget.story.by,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SlidableAction(
|
const Spacer(),
|
||||||
onPressed: (_) => onMorePressed(widget.story),
|
Text(
|
||||||
backgroundColor: Colors.orange,
|
widget.story.postedDate,
|
||||||
foregroundColor: Colors.white,
|
style: const TextStyle(
|
||||||
icon: Icons.more_horiz,
|
color: Colors.grey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
),
|
||||||
children: <Widget>[
|
InkWell(
|
||||||
Padding(
|
onTap: () => LinkUtil.launch(
|
||||||
padding: const EdgeInsets.only(
|
widget.story.url,
|
||||||
left: 6,
|
useReader: context
|
||||||
right: 6,
|
.read<PreferenceCubit>()
|
||||||
),
|
.state
|
||||||
child: Row(
|
.useReader,
|
||||||
children: <Widget>[
|
),
|
||||||
Text(
|
child: Padding(
|
||||||
widget.story.by,
|
padding: const EdgeInsets.only(
|
||||||
style: const TextStyle(
|
left: 6,
|
||||||
color: Colors.orange,
|
right: 6,
|
||||||
),
|
bottom: 12,
|
||||||
),
|
top: 12,
|
||||||
const Spacer(),
|
),
|
||||||
Text(
|
child: Text(
|
||||||
widget.story.postedDate,
|
widget.story.title,
|
||||||
style: const TextStyle(
|
textAlign: TextAlign.center,
|
||||||
color: Colors.grey,
|
style: TextStyle(
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
),
|
color: widget.story.url.isNotEmpty
|
||||||
],
|
? Colors.orange
|
||||||
),
|
: null,
|
||||||
),
|
),
|
||||||
InkWell(
|
),
|
||||||
onTap: () => LinkUtil.launch(
|
|
||||||
widget.story.url,
|
|
||||||
useReader: context
|
|
||||||
.read<PreferenceCubit>()
|
|
||||||
.state
|
|
||||||
.useReader,
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: 6,
|
|
||||||
right: 6,
|
|
||||||
bottom: 12,
|
|
||||||
top: 12,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
widget.story.title,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: widget.story.url.isNotEmpty
|
|
||||||
? Colors.orange
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.story.text.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
),
|
|
||||||
child: SelectableLinkify(
|
|
||||||
text: widget.story.text,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: MediaQuery.of(context)
|
|
||||||
.textScaleFactor *
|
|
||||||
15,
|
|
||||||
),
|
|
||||||
linkStyle: TextStyle(
|
|
||||||
fontSize: MediaQuery.of(context)
|
|
||||||
.textScaleFactor *
|
|
||||||
15,
|
|
||||||
color: Colors.orange,
|
|
||||||
),
|
|
||||||
onOpen: (LinkableElement link) {
|
|
||||||
if (link.url.contains(
|
|
||||||
'news.ycombinator.com/item',
|
|
||||||
)) {
|
|
||||||
onStoryLinkTapped(link.url);
|
|
||||||
} else {
|
|
||||||
LinkUtil.launch(link.url);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.story.isPoll)
|
|
||||||
PollView(
|
|
||||||
onLoginTapped: onLoginTapped,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.story.text.isNotEmpty)
|
if (widget.story.text.isNotEmpty)
|
||||||
const SizedBox(
|
Padding(
|
||||||
height: 8,
|
padding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 10,
|
||||||
const Divider(
|
),
|
||||||
height: 0,
|
child: SelectableLinkify(
|
||||||
),
|
text: widget.story.text,
|
||||||
if (state.onlyShowTargetComment) ...<Widget>[
|
style: TextStyle(
|
||||||
TextButton(
|
fontSize:
|
||||||
onPressed: () => context
|
MediaQuery.of(context).textScaleFactor *
|
||||||
.read<CommentsCubit>()
|
15,
|
||||||
.loadAll(widget.story),
|
),
|
||||||
child: const Text('View all comments'),
|
linkStyle: TextStyle(
|
||||||
),
|
fontSize:
|
||||||
const Divider(
|
MediaQuery.of(context).textScaleFactor *
|
||||||
height: 0,
|
15,
|
||||||
),
|
color: Colors.orange,
|
||||||
],
|
),
|
||||||
if (state.comments.isEmpty &&
|
onOpen: (LinkableElement link) {
|
||||||
state.status ==
|
if (link.url.contains(
|
||||||
CommentsStatus.allLoaded) ...<Widget>[
|
'news.ycombinator.com/item',
|
||||||
const SizedBox(
|
)) {
|
||||||
height: 240,
|
onStoryLinkTapped(link.url);
|
||||||
),
|
} else {
|
||||||
const Center(
|
LinkUtil.launch(link.url);
|
||||||
child: Text(
|
}
|
||||||
'Nothing yet',
|
},
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (widget.story.isPoll)
|
||||||
|
PollView(
|
||||||
|
onLoginTapped: onLoginTapped,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
|
||||||
} else if (index ==
|
|
||||||
state.comments.length + _extraItemsCount - 1) {
|
|
||||||
if ((state.status == CommentsStatus.allLoaded &&
|
|
||||||
state.comments.isNotEmpty) ||
|
|
||||||
state.onlyShowTargetComment) {
|
|
||||||
return SizedBox(
|
|
||||||
height: 240,
|
|
||||||
child: Center(
|
|
||||||
child: Text(happyFace),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
final Comment comment = state.comments.elementAt(index - 1);
|
|
||||||
|
|
||||||
return FadeIn(
|
|
||||||
key: ValueKey<int>(comment.id),
|
|
||||||
child: CommentTile(
|
|
||||||
comment: comment,
|
|
||||||
level: comment.level,
|
|
||||||
myUsername:
|
|
||||||
authState.isLoggedIn ? authState.username : null,
|
|
||||||
opUsername: widget.story.by,
|
|
||||||
onReplyTapped: (Comment cmt) {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
if (cmt.deleted || cmt.dead) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmt !=
|
|
||||||
context.read<EditCubit>().state.replyingTo) {
|
|
||||||
commentEditingController.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
context.read<EditCubit>().onReplyTapped(cmt);
|
|
||||||
focusNode.requestFocus();
|
|
||||||
},
|
|
||||||
onEditTapped: (Comment cmt) {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
if (cmt.deleted || cmt.dead) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
commentEditingController.clear();
|
|
||||||
context.read<EditCubit>().onEditTapped(cmt);
|
|
||||||
focusNode.requestFocus();
|
|
||||||
},
|
|
||||||
onMoreTapped: onMorePressed,
|
|
||||||
onStoryLinkTapped: onStoryLinkTapped,
|
|
||||||
onTimeMachineActivated: onTimeMachineActivated,
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
if (widget.story.text.isNotEmpty)
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
height: 0,
|
||||||
|
),
|
||||||
|
if (state.onlyShowTargetComment) ...<Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
context.read<CommentsCubit>().loadAll(widget.story),
|
||||||
|
child: const Text('View all comments'),
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
height: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (state.comments.isEmpty &&
|
||||||
|
state.status == CommentsStatus.allLoaded) ...<Widget>[
|
||||||
|
const SizedBox(
|
||||||
|
height: 240,
|
||||||
|
),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'Nothing yet',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
for (final Comment comment in state.comments)
|
||||||
|
FadeIn(
|
||||||
|
key: ValueKey<String>('${comment.id}-FadeIn'),
|
||||||
|
child: CommentTile(
|
||||||
|
comment: comment,
|
||||||
|
level: comment.level,
|
||||||
|
myUsername:
|
||||||
|
authState.isLoggedIn ? authState.username : null,
|
||||||
|
opUsername: widget.story.by,
|
||||||
|
onReplyTapped: (Comment cmt) {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
if (cmt.deleted || cmt.dead) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmt !=
|
||||||
|
context.read<EditCubit>().state.replyingTo) {
|
||||||
|
commentEditingController.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.read<EditCubit>().onReplyTapped(cmt);
|
||||||
|
focusNode.requestFocus();
|
||||||
|
},
|
||||||
|
onEditTapped: (Comment cmt) {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
if (cmt.deleted || cmt.dead) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commentEditingController.clear();
|
||||||
|
context.read<EditCubit>().onEditTapped(cmt);
|
||||||
|
focusNode.requestFocus();
|
||||||
|
},
|
||||||
|
onMoreTapped: onMorePressed,
|
||||||
|
onStoryLinkTapped: onStoryLinkTapped,
|
||||||
|
onTimeMachineActivated: onTimeMachineActivated,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ((state.status == CommentsStatus.allLoaded &&
|
||||||
|
state.comments.isNotEmpty) ||
|
||||||
|
state.onlyShowTargetComment)
|
||||||
|
SizedBox(
|
||||||
|
height: 240,
|
||||||
|
child: Center(
|
||||||
|
child: Text(happyFace),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -766,12 +756,82 @@ class _StoryScreenState extends State<StoryScreen> {
|
|||||||
final bool upvoted = voteState.vote == Vote.up;
|
final bool upvoted = voteState.vote == Vote.up;
|
||||||
final bool downvoted = voteState.vote == Vote.down;
|
final bool downvoted = voteState.vote == Vote.down;
|
||||||
return Container(
|
return Container(
|
||||||
height: 300,
|
height: item is Comment ? 370 : 390,
|
||||||
color: Theme.of(context).canvasColor,
|
color: Theme.of(context).canvasColor,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
BlocProvider<UserCubit>(
|
||||||
|
create: (BuildContext context) =>
|
||||||
|
UserCubit()..init(userId: item.by),
|
||||||
|
child: BlocBuilder<UserCubit, UserState>(
|
||||||
|
builder: (BuildContext context, UserState state) {
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.account_circle,
|
||||||
|
),
|
||||||
|
title: Text(item.by),
|
||||||
|
subtitle: Text(
|
||||||
|
state.user.description,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) =>
|
||||||
|
SimpleDialog(
|
||||||
|
title: Text('About ${state.user.id}'),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
children: <Widget>[
|
||||||
|
SelectableLinkify(
|
||||||
|
text: HtmlUtil.parseHtml(
|
||||||
|
state.user.about,
|
||||||
|
),
|
||||||
|
linkStyle: const TextStyle(
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
onOpen: (LinkableElement link) {
|
||||||
|
if (link.url.contains(
|
||||||
|
'news.ycombinator.com/item',
|
||||||
|
)) {
|
||||||
|
onStoryLinkTapped.call(link.url);
|
||||||
|
} else {
|
||||||
|
LinkUtil.launch(link.url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ButtonBar(
|
||||||
|
children: <Widget>[
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(context),
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor:
|
||||||
|
MaterialStateProperty.all(
|
||||||
|
Colors.deepOrange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Okay',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
FeatherIcons.chevronUp,
|
FeatherIcons.chevronUp,
|
||||||
|
@ -25,6 +25,7 @@ class FavIconButton extends StatelessWidget {
|
|||||||
builder: (BuildContext context, FavState favState) {
|
builder: (BuildContext context, FavState favState) {
|
||||||
final bool isFav = favState.favIds.contains(storyId);
|
final bool isFav = favState.favIds.contains(storyId);
|
||||||
return IconButton(
|
return IconButton(
|
||||||
|
tooltip: 'Add to favorites',
|
||||||
icon: DescribedFeatureOverlay(
|
icon: DescribedFeatureOverlay(
|
||||||
onBackgroundTap: onBackgroundTap,
|
onBackgroundTap: onBackgroundTap,
|
||||||
onDismiss: onDismiss,
|
onDismiss: onDismiss,
|
||||||
|
@ -21,6 +21,7 @@ class LinkIconButton extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
|
tooltip: 'Open this story in browser',
|
||||||
icon: DescribedFeatureOverlay(
|
icon: DescribedFeatureOverlay(
|
||||||
onBackgroundTap: onBackgroundTap,
|
onBackgroundTap: onBackgroundTap,
|
||||||
onDismiss: onDismiss,
|
onDismiss: onDismiss,
|
||||||
|
@ -31,6 +31,7 @@ class PinIconButton extends StatelessWidget {
|
|||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
offset: const Offset(2, 0),
|
offset: const Offset(2, 0),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
|
tooltip: 'Pin to home screen',
|
||||||
icon: DescribedFeatureOverlay(
|
icon: DescribedFeatureOverlay(
|
||||||
onBackgroundTap: onBackgroundTap,
|
onBackgroundTap: onBackgroundTap,
|
||||||
onDismiss: onDismiss,
|
onDismiss: onDismiss,
|
||||||
|
@ -38,6 +38,7 @@ class CommentTile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<CollapseCubit>(
|
return BlocProvider<CollapseCubit>(
|
||||||
|
key: ValueKey<String>('${comment.id}-BlocProvider'),
|
||||||
lazy: false,
|
lazy: false,
|
||||||
create: (_) => CollapseCubit(
|
create: (_) => CollapseCubit(
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
@ -209,30 +210,62 @@ class CommentTile extends StatelessWidget {
|
|||||||
top: 6,
|
top: 6,
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
),
|
),
|
||||||
child: SelectableLinkify(
|
child: comment is BuildableComment
|
||||||
key: ObjectKey(comment),
|
? SelectableText.rich(
|
||||||
text: comment.text,
|
key: ValueKey<int>(comment.id),
|
||||||
style: TextStyle(
|
buildTextSpan(
|
||||||
fontSize: MediaQuery.of(context)
|
(comment as BuildableComment)
|
||||||
.textScaleFactor *
|
.elements,
|
||||||
15,
|
style: TextStyle(
|
||||||
),
|
fontSize: MediaQuery.of(context)
|
||||||
linkStyle: TextStyle(
|
.textScaleFactor *
|
||||||
fontSize: MediaQuery.of(context)
|
15,
|
||||||
.textScaleFactor *
|
),
|
||||||
15,
|
linkStyle: TextStyle(
|
||||||
color: Colors.orange,
|
fontSize: MediaQuery.of(context)
|
||||||
),
|
.textScaleFactor *
|
||||||
onOpen: (LinkableElement link) {
|
15,
|
||||||
if (link.url.contains(
|
decoration:
|
||||||
'news.ycombinator.com/item',
|
TextDecoration.underline,
|
||||||
)) {
|
color: Colors.orange,
|
||||||
onStoryLinkTapped.call(link.url);
|
),
|
||||||
} else {
|
onOpen: (LinkableElement link) {
|
||||||
LinkUtil.launch(link.url);
|
if (link.url.contains(
|
||||||
}
|
'news.ycombinator.com/item',
|
||||||
},
|
)) {
|
||||||
),
|
onStoryLinkTapped
|
||||||
|
.call(link.url);
|
||||||
|
} else {
|
||||||
|
LinkUtil.launch(link.url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SelectableLinkify(
|
||||||
|
key: ValueKey<int>(comment.id),
|
||||||
|
text: comment.text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: MediaQuery.of(context)
|
||||||
|
.textScaleFactor *
|
||||||
|
15,
|
||||||
|
),
|
||||||
|
linkStyle: TextStyle(
|
||||||
|
fontSize: MediaQuery.of(context)
|
||||||
|
.textScaleFactor *
|
||||||
|
15,
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
onOpen: (LinkableElement link) {
|
||||||
|
if (link.url.contains(
|
||||||
|
'news.ycombinator.com/item',
|
||||||
|
)) {
|
||||||
|
onStoryLinkTapped
|
||||||
|
.call(link.url);
|
||||||
|
} else {
|
||||||
|
LinkUtil.launch(link.url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Divider(
|
const Divider(
|
||||||
height: 0,
|
height: 0,
|
||||||
|
@ -87,7 +87,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
child: StoryTile(
|
child: StoryTile(
|
||||||
key: ObjectKey(e),
|
key: ValueKey<int>(e.id),
|
||||||
story: e,
|
story: e,
|
||||||
onTap: () => onTap(e),
|
onTap: () => onTap(e),
|
||||||
showWebPreview: showWebPreview,
|
showWebPreview: showWebPreview,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
name: hacki
|
name: hacki
|
||||||
description: A Hacker News reader.
|
description: A Hacker News reader.
|
||||||
version: 0.2.16+55
|
version: 0.2.17+56
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
Reference in New Issue
Block a user