mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
8348a87a75 | |||
58837b6c00 | |||
8365869ee8 | |||
5c70185236 | |||
e4a385deb7 | |||
dfde6a74eb | |||
a2223dc531 | |||
f75e6a5e3b | |||
a87d521d32 | |||
94d76d4c20 | |||
1176e3bb80 |
@ -1,6 +1,6 @@
|
||||
# Hacki
|
||||
# Hacki for Hacker News
|
||||
|
||||
A simple Hacker News reader made with Flutter.
|
||||
A simple noiseless Hacker News reader made with Flutter that is just enough.
|
||||
|
||||

|
||||
[](https://apps.apple.com/us/app/hacki/id1602043763)
|
||||
|
1
fastlane/metadata/android/en-US/changelogs/26.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/26.txt
Normal file
@ -0,0 +1 @@
|
||||
- Tapping on comments in notification and history screen will lead you directly to the comment.
|
@ -356,7 +356,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -365,7 +365,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.8;
|
||||
MARKETING_VERSION = 0.1.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -491,7 +491,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -500,7 +500,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.8;
|
||||
MARKETING_VERSION = 0.1.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -520,7 +520,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -529,7 +529,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.8;
|
||||
MARKETING_VERSION = 0.1.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
@ -8,21 +8,31 @@ import 'package:hacki/services/cache_service.dart';
|
||||
part 'comments_state.dart';
|
||||
|
||||
class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
||||
CommentsCubit(
|
||||
{required T item,
|
||||
CacheService? cacheService,
|
||||
StoriesRepository? storiesRepository})
|
||||
: _cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
CommentsCubit({
|
||||
CacheService? cacheService,
|
||||
StoriesRepository? storiesRepository,
|
||||
}) : _cacheService = cacheService ?? locator.get<CacheService>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
super(CommentsState.init()) {
|
||||
init(item);
|
||||
}
|
||||
super(CommentsState.init());
|
||||
|
||||
final CacheService _cacheService;
|
||||
final StoriesRepository _storiesRepository;
|
||||
|
||||
Future<void> init(T item) async {
|
||||
Future<void> init(
|
||||
T item, {
|
||||
bool onlyShowTargetComment = false,
|
||||
Comment? targetComment,
|
||||
}) async {
|
||||
if (onlyShowTargetComment) {
|
||||
emit(state.copyWith(
|
||||
item: item,
|
||||
comments: targetComment != null ? [targetComment] : [],
|
||||
onlyShowTargetComment: true,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (item is Story) {
|
||||
final story = item;
|
||||
final updatedStory = await _storiesRepository.fetchStoryById(story.id);
|
||||
@ -97,6 +107,14 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
||||
emit(state.copyWith(collapsed: !state.collapsed));
|
||||
}
|
||||
|
||||
void loadAll(T item) {
|
||||
emit(state.copyWith(
|
||||
onlyShowTargetComment: false,
|
||||
comments: [],
|
||||
));
|
||||
init(item);
|
||||
}
|
||||
|
||||
void _onCommentFetched(Comment? comment) {
|
||||
if (comment != null) {
|
||||
_cacheService.cacheComment(comment);
|
||||
|
@ -13,30 +13,36 @@ class CommentsState extends Equatable {
|
||||
required this.comments,
|
||||
required this.status,
|
||||
required this.collapsed,
|
||||
required this.onlyShowTargetComment,
|
||||
});
|
||||
|
||||
CommentsState.init()
|
||||
: item = null,
|
||||
comments = [],
|
||||
status = CommentsStatus.init,
|
||||
collapsed = false;
|
||||
collapsed = false,
|
||||
onlyShowTargetComment = false;
|
||||
|
||||
final Item? item;
|
||||
final List<Comment> comments;
|
||||
final CommentsStatus status;
|
||||
final bool collapsed;
|
||||
final bool onlyShowTargetComment;
|
||||
|
||||
CommentsState copyWith({
|
||||
Item? item,
|
||||
List<Comment>? comments,
|
||||
CommentsStatus? status,
|
||||
bool? collapsed,
|
||||
bool? onlyShowTargetComment,
|
||||
}) {
|
||||
return CommentsState(
|
||||
item: item ?? this.item,
|
||||
comments: comments ?? this.comments,
|
||||
status: status ?? this.status,
|
||||
collapsed: collapsed ?? this.collapsed,
|
||||
onlyShowTargetComment:
|
||||
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
||||
);
|
||||
}
|
||||
|
||||
@ -46,5 +52,6 @@ class CommentsState extends Equatable {
|
||||
comments,
|
||||
status,
|
||||
collapsed,
|
||||
onlyShowTargetComment,
|
||||
];
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import 'dart:math';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
|
@ -1,7 +1,8 @@
|
||||
import 'package:firebase/firebase_io.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:html_unescape/html_unescape.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class StoriesRepository {
|
||||
StoriesRepository({
|
||||
@ -128,7 +129,7 @@ class StoriesRepository {
|
||||
Future<Item?> fetchItemBy({required int id}) async {
|
||||
final item = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic val) {
|
||||
.then((dynamic val) async {
|
||||
if (val == null) {
|
||||
return null;
|
||||
}
|
||||
@ -138,6 +139,9 @@ class StoriesRepository {
|
||||
final story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (json['type'] == 'comment') {
|
||||
final text = json['text'] as String? ?? '';
|
||||
final parsedText = await compute<String, String>(_parseHtml, text);
|
||||
json['text'] = parsedText;
|
||||
final comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
@ -172,6 +176,22 @@ class StoriesRepository {
|
||||
return item as Story;
|
||||
}
|
||||
|
||||
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments(
|
||||
{required int id}) async {
|
||||
Item? item;
|
||||
final parentComments = <Comment>[];
|
||||
|
||||
do {
|
||||
item = await fetchItemBy(id: item?.parent ?? id);
|
||||
if (item is Comment) {
|
||||
parentComments.add(item);
|
||||
}
|
||||
if (item == null) return null;
|
||||
} while (item is Comment);
|
||||
|
||||
return Tuple2<Story, List<Comment>>(item as Story, parentComments);
|
||||
}
|
||||
|
||||
static String _parseHtml(String text) {
|
||||
return HtmlUnescape()
|
||||
.convert(text)
|
||||
|
@ -248,9 +248,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
'to check out stories and comments you have '
|
||||
'posted in the past, and get in-app '
|
||||
'notification when there is new reply to '
|
||||
'your comments or stories.\n\nAlso, you can '
|
||||
'long press here to submit a new link to '
|
||||
'Hacker News.',
|
||||
'your comments or stories.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
child: BlocBuilder<NotificationCubit,
|
||||
|
@ -40,6 +40,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
final refreshControllerFav = RefreshController();
|
||||
final refreshControllerNotification = RefreshController();
|
||||
final scrollController = ScrollController();
|
||||
final throttle = Throttle(delay: const Duration(seconds: 2));
|
||||
|
||||
_PageType pageType = _PageType.notification;
|
||||
|
||||
@ -52,6 +53,16 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
'to infinity and beyond!',
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
refreshControllerHistory.dispose();
|
||||
refreshControllerFav.dispose();
|
||||
refreshControllerNotification.dispose();
|
||||
scrollController.dispose();
|
||||
throttle.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@ -86,8 +97,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
}
|
||||
},
|
||||
builder: (context, historyState) {
|
||||
if (!authState.isLoggedIn ||
|
||||
historyState.submittedItems.isEmpty) {
|
||||
if ((!authState.isLoggedIn ||
|
||||
historyState.submittedItems.isEmpty) &&
|
||||
historyState.status != HistoryStatus.loading) {
|
||||
return const _CenteredMessageView(
|
||||
content: 'Your past comments and stories will '
|
||||
'show up here.',
|
||||
@ -96,6 +108,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
|
||||
return ItemsListView<Item>(
|
||||
showWebPreview: false,
|
||||
useConsistentFontSize: true,
|
||||
refreshController: refreshControllerHistory,
|
||||
items: historyState.submittedItems
|
||||
.where((e) => !e.dead && !e.deleted)
|
||||
@ -113,17 +126,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
StoryScreen.routeName,
|
||||
arguments: StoryScreenArgs(story: item));
|
||||
} else if (item is Comment) {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchParentStory(id: item.parent)
|
||||
.then((story) {
|
||||
if (story != null && mounted) {
|
||||
HackiApp.navigatorKey.currentState!
|
||||
.pushNamed(StoryScreen.routeName,
|
||||
arguments: StoryScreenArgs(
|
||||
story: story));
|
||||
}
|
||||
});
|
||||
onCommentTapped(item);
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -144,7 +147,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
}
|
||||
},
|
||||
builder: (context, favState) {
|
||||
if (favState.favStories.isEmpty) {
|
||||
if (favState.favStories.isEmpty &&
|
||||
favState.status != FavStatus.loading) {
|
||||
return const _CenteredMessageView(
|
||||
content:
|
||||
'Your favorite stories will show up here.'
|
||||
@ -190,22 +194,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
unreadCommentsIds:
|
||||
notificationState.unreadCommentsIds,
|
||||
comments: notificationState.comments,
|
||||
onCommentTapped: (comment) {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchParentStory(id: comment.parent)
|
||||
.then((story) {
|
||||
if (story != null && mounted) {
|
||||
context
|
||||
.read<NotificationCubit>()
|
||||
.markAsRead(comment);
|
||||
HackiApp.navigatorKey.currentState!.pushNamed(
|
||||
StoryScreen.routeName,
|
||||
arguments: StoryScreenArgs(
|
||||
story: story,
|
||||
),
|
||||
);
|
||||
}
|
||||
onCommentTapped: (cmt) {
|
||||
onCommentTapped(cmt, then: () {
|
||||
context.read<NotificationCubit>().markAsRead(cmt);
|
||||
});
|
||||
},
|
||||
onMarkAllAsReadTapped: () {
|
||||
@ -381,7 +372,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'Hacki',
|
||||
applicationVersion: 'v0.1.8',
|
||||
applicationVersion: 'v0.1.9',
|
||||
applicationIcon: Image.asset(
|
||||
Constants.hackiIconPath,
|
||||
height: 50,
|
||||
@ -595,6 +586,30 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void onCommentTapped(Comment comment, {VoidCallback? then}) {
|
||||
throttle.run(() {
|
||||
locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchParentStoryWithComments(id: comment.parent)
|
||||
.then((tuple) {
|
||||
if (tuple != null && mounted) {
|
||||
HackiApp.navigatorKey.currentState!
|
||||
.pushNamed(
|
||||
StoryScreen.routeName,
|
||||
arguments: StoryScreenArgs(
|
||||
story: tuple.item1,
|
||||
targetComments: tuple.item2.isEmpty
|
||||
? [comment]
|
||||
: [comment, ...tuple.item2],
|
||||
onlyShowTargetComment: true,
|
||||
),
|
||||
)
|
||||
.then((_) => then?.call());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void onLoginTapped() {
|
||||
final usernameController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
|
@ -31,13 +31,21 @@ enum _MenuAction {
|
||||
}
|
||||
|
||||
class StoryScreenArgs {
|
||||
StoryScreenArgs({required this.story});
|
||||
StoryScreenArgs({
|
||||
required this.story,
|
||||
this.onlyShowTargetComment = false,
|
||||
this.targetComments,
|
||||
});
|
||||
|
||||
final Story story;
|
||||
final bool onlyShowTargetComment;
|
||||
final List<Comment>? targetComments;
|
||||
}
|
||||
|
||||
class StoryScreen extends StatefulWidget {
|
||||
const StoryScreen({Key? key, required this.story}) : super(key: key);
|
||||
const StoryScreen(
|
||||
{Key? key, required this.story, required this.parentComments})
|
||||
: super(key: key);
|
||||
|
||||
static const String routeName = '/story';
|
||||
|
||||
@ -50,9 +58,12 @@ class StoryScreen extends StatefulWidget {
|
||||
create: (context) => PostCubit(),
|
||||
),
|
||||
BlocProvider<CommentsCubit>(
|
||||
create: (_) => CommentsCubit<Story>(
|
||||
item: args.story,
|
||||
),
|
||||
create: (_) => CommentsCubit<Story>()
|
||||
..init(
|
||||
args.story,
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetComment: args.targetComments?.last,
|
||||
),
|
||||
),
|
||||
BlocProvider<EditCubit>(
|
||||
create: (context) => EditCubit(),
|
||||
@ -60,12 +71,14 @@ class StoryScreen extends StatefulWidget {
|
||||
],
|
||||
child: StoryScreen(
|
||||
story: args.story,
|
||||
parentComments: args.targetComments ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Story story;
|
||||
final List<Comment> parentComments;
|
||||
|
||||
@override
|
||||
_StoryScreenState createState() => _StoryScreenState();
|
||||
@ -338,7 +351,8 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
),
|
||||
body: SmartRefresher(
|
||||
scrollController: scrollController,
|
||||
enablePullUp: true,
|
||||
enablePullUp: !state.onlyShowTargetComment,
|
||||
enablePullDown: !state.onlyShowTargetComment,
|
||||
header: WaterDropMaterialHeader(
|
||||
backgroundColor: Colors.orange,
|
||||
offset: topPadding,
|
||||
@ -458,17 +472,37 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
),
|
||||
),
|
||||
if (widget.story.text.isNotEmpty)
|
||||
Html(
|
||||
data: widget.story.text,
|
||||
onLinkTap: (link, _, __, ___) =>
|
||||
LinkUtil.launchUrl(link ?? ''),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
),
|
||||
child: SelectableHtml(
|
||||
data: widget.story.text,
|
||||
onLinkTap: (link, _, __, ___) =>
|
||||
LinkUtil.launchUrl(link ?? ''),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.story.text.isNotEmpty)
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const Divider(
|
||||
height: 0,
|
||||
),
|
||||
if (state.onlyShowTargetComment) ...[
|
||||
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.loaded) ...[
|
||||
const SizedBox(
|
||||
@ -485,6 +519,11 @@ class _StoryScreenState extends State<StoryScreen> {
|
||||
(e) => FadeIn(
|
||||
child: CommentTile(
|
||||
comment: e,
|
||||
onlyShowTargetComment:
|
||||
state.onlyShowTargetComment,
|
||||
targetComments: widget.parentComments.sublist(
|
||||
0,
|
||||
max(widget.parentComments.length - 1, 0)),
|
||||
myUsername: authState.isLoggedIn
|
||||
? authState.username
|
||||
: null,
|
||||
|
@ -76,20 +76,21 @@ class _ReplyBoxState extends State<ReplyBox> {
|
||||
),
|
||||
const Spacer(),
|
||||
if (!widget.isLoading) ...[
|
||||
if (widget.replyingTo != null) ...[
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: IconButton(
|
||||
key: const Key('quote'),
|
||||
icon: const Icon(
|
||||
FeatherIcons.code,
|
||||
color: Colors.orange,
|
||||
size: 18,
|
||||
...[
|
||||
if (widget.replyingTo != null)
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: IconButton(
|
||||
key: const Key('quote'),
|
||||
icon: const Icon(
|
||||
FeatherIcons.code,
|
||||
color: Colors.orange,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: expanded ? showTextPopup : null,
|
||||
),
|
||||
onPressed: expanded ? showTextPopup : null,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('expand'),
|
||||
icon: Icon(
|
||||
|
@ -1,10 +1,12 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
@ -19,22 +21,31 @@ class CommentTile extends StatelessWidget {
|
||||
required this.onEditTapped,
|
||||
required this.onStoryLinkTapped,
|
||||
this.loadKids = true,
|
||||
this.onlyShowTargetComment = false,
|
||||
this.level = 0,
|
||||
this.targetComments = const [],
|
||||
}) : super(key: key);
|
||||
|
||||
final String? myUsername;
|
||||
final Comment comment;
|
||||
final int level;
|
||||
final bool loadKids;
|
||||
final bool onlyShowTargetComment;
|
||||
final Function(Comment) onReplyTapped;
|
||||
final Function(Comment) onMoreTapped;
|
||||
final Function(Comment) onEditTapped;
|
||||
final Function(String) onStoryLinkTapped;
|
||||
final List<Comment> targetComments;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CommentsCubit>(
|
||||
create: (_) => CommentsCubit<Comment>(item: comment),
|
||||
lazy: false,
|
||||
create: (_) => CommentsCubit<Comment>()
|
||||
..init(comment,
|
||||
onlyShowTargetComment: onlyShowTargetComment,
|
||||
targetComment:
|
||||
targetComments.isNotEmpty ? targetComments.last : null),
|
||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||
builder: (context, state) {
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
@ -201,6 +212,16 @@ class CommentTile extends StatelessWidget {
|
||||
(e) => FadeIn(
|
||||
child: CommentTile(
|
||||
comment: e,
|
||||
onlyShowTargetComment:
|
||||
onlyShowTargetComment &&
|
||||
targetComments.length > 1,
|
||||
targetComments: targetComments
|
||||
.isNotEmpty
|
||||
? targetComments.sublist(
|
||||
0,
|
||||
max(targetComments.length - 1,
|
||||
0))
|
||||
: [],
|
||||
myUsername: myUsername,
|
||||
onReplyTapped: onReplyTapped,
|
||||
onMoreTapped: onMoreTapped,
|
||||
|
@ -6,8 +6,7 @@ import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/custom_circular_progress_indicator.dart';
|
||||
import 'package:hacki/screens/widgets/story_tile.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
@ -21,6 +20,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
this.enablePullDown = true,
|
||||
this.pinnable = false,
|
||||
this.markReadStories = false,
|
||||
this.useConsistentFontSize = false,
|
||||
this.onRefresh,
|
||||
this.onLoadMore,
|
||||
this.onPinned,
|
||||
@ -34,6 +34,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
|
||||
/// Whether story tiles can be pinned to the top.
|
||||
final bool pinnable;
|
||||
|
||||
/// Whether to use same font size for comment and story tiles.
|
||||
final bool useConsistentFontSize;
|
||||
|
||||
final List<T> items;
|
||||
final Widget? header;
|
||||
final RefreshController? refreshController;
|
||||
@ -79,6 +83,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
onTap: () => onTap(e),
|
||||
showWebPreview: showWebPreview,
|
||||
wasRead: markReadStories && wasRead,
|
||||
simpleTileFontSize: useConsistentFontSize ? 14 : 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -14,12 +14,14 @@ class StoryTile extends StatelessWidget {
|
||||
required this.showWebPreview,
|
||||
required this.story,
|
||||
required this.onTap,
|
||||
this.simpleTileFontSize = 16,
|
||||
}) : super(key: key);
|
||||
|
||||
final bool showWebPreview;
|
||||
final bool wasRead;
|
||||
final Story story;
|
||||
final VoidCallback onTap;
|
||||
final double simpleTileFontSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -178,7 +180,7 @@ class StoryTile extends StatelessWidget {
|
||||
story.title,
|
||||
style: TextStyle(
|
||||
color: wasRead ? Colors.grey[500] : null,
|
||||
fontSize: 16,
|
||||
fontSize: simpleTileFontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
119
lib/services/firebase_client.dart
Normal file
119
lib/services/firebase_client.dart
Normal file
@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
|
||||
/// FirebaseClient wraps a REST client for a Firebase realtime database.
|
||||
///
|
||||
/// The client supports authentication and GET, PUT, POST, DELETE
|
||||
/// and PATCH methods.
|
||||
class FirebaseClient {
|
||||
/// Creates a new FirebaseClient with [credential] and optional [client].
|
||||
///
|
||||
/// For credential you can either use Firebase app's secret or
|
||||
/// an authentication token.
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/user-auth>.
|
||||
FirebaseClient(this.credential, {Client? client})
|
||||
: _client = client ?? Client();
|
||||
|
||||
/// Creates a new anonymous FirebaseClient with optional [client].
|
||||
FirebaseClient.anonymous({Client? client})
|
||||
: credential = null,
|
||||
_client = client ?? Client();
|
||||
|
||||
/// Auth credential.
|
||||
final String? credential;
|
||||
final Client _client;
|
||||
|
||||
/// Reads data from database using a HTTP GET request.
|
||||
/// The response from a successful request contains a data being retrieved.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-get>.
|
||||
Future<dynamic> get(dynamic uri) => send('GET', uri);
|
||||
|
||||
/// Writes or replaces data in database using a HTTP PUT request.
|
||||
/// The response from a successful request contains a data being written.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-put>.
|
||||
Future<dynamic> put(dynamic uri, dynamic json) =>
|
||||
send('PUT', uri, json: json);
|
||||
|
||||
/// Pushes data to database using a HTTP POST request.
|
||||
/// The response from a successful request contains a key of the new data
|
||||
/// being added.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-post>.
|
||||
Future<dynamic> post(dynamic uri, dynamic json) =>
|
||||
send('POST', uri, json: json);
|
||||
|
||||
/// Updates specific children at a location without overwriting existing data
|
||||
/// using a HTTP PATCH request.
|
||||
/// The response from a successful request contains a data being written.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-patch>.
|
||||
Future<dynamic> patch(dynamic uri, dynamic json) =>
|
||||
send('PATCH', uri, json: json);
|
||||
|
||||
/// Deletes data from database using a HTTP DELETE request.
|
||||
/// The response from a successful request contains a JSON with `null`.
|
||||
///
|
||||
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-delete>.
|
||||
Future<void> delete(dynamic uri) => send('DELETE', uri);
|
||||
|
||||
/// Creates a request with a HTTP [method], [url] and optional data.
|
||||
/// The [url] can be either a `String` or `Uri`.
|
||||
Future<Object?> send(String method, dynamic url, {dynamic json}) async {
|
||||
final uri = url is String ? Uri.parse(url) : url as Uri;
|
||||
|
||||
final request = Request(method, uri);
|
||||
if (credential != null) {
|
||||
request.headers['Authorization'] = 'Bearer $credential';
|
||||
}
|
||||
|
||||
if (json != null) {
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
request.body = jsonEncode(json);
|
||||
}
|
||||
|
||||
final streamedResponse = await _client.send(request);
|
||||
final response = await Response.fromStream(streamedResponse);
|
||||
|
||||
Object? bodyJson;
|
||||
try {
|
||||
bodyJson = jsonDecode(response.body);
|
||||
} on FormatException {
|
||||
final contentType = response.headers['content-type'];
|
||||
if (contentType != null && !contentType.contains('application/json')) {
|
||||
throw Exception(
|
||||
"Returned value was not JSON. Did the uri end with '.json'?");
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (bodyJson is Map) {
|
||||
final dynamic error = bodyJson['error'];
|
||||
if (error != null) {
|
||||
throw FirebaseClientException(response.statusCode, error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
throw FirebaseClientException(response.statusCode, bodyJson.toString());
|
||||
}
|
||||
|
||||
return bodyJson;
|
||||
}
|
||||
|
||||
/// Closes the client and cleans up any associated resources.
|
||||
void close() => _client.close();
|
||||
}
|
||||
|
||||
class FirebaseClientException implements Exception {
|
||||
FirebaseClientException(this.statusCode, this.message);
|
||||
|
||||
final int statusCode;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => '$message ($statusCode)';
|
||||
}
|
@ -1 +1,2 @@
|
||||
export 'cache_service.dart';
|
||||
export 'firebase_client.dart';
|
||||
|
@ -1,6 +1,6 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 0.1.8+25
|
||||
version: 0.1.9+27
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
@ -18,8 +18,6 @@ dependencies:
|
||||
equatable: 2.0.3
|
||||
fast_gbk: ^1.0.0
|
||||
feature_discovery: ^0.14.0
|
||||
firebase_analytics: ^8.3.4
|
||||
firebase_core: ^1.6.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_app_badger: ^1.3.0
|
||||
@ -46,6 +44,7 @@ dependencies:
|
||||
sembast: ^3.1.1+1
|
||||
shared_preferences: ^2.0.11
|
||||
shimmer: ^2.0.0
|
||||
tuple: ^2.0.0
|
||||
universal_platform: ^1.0.0+1
|
||||
url_launcher: ^6.0.10
|
||||
|
||||
|
Reference in New Issue
Block a user