diff --git a/lib/controls/timeline/status_header_control.dart b/lib/controls/timeline/status_header_control.dart index 313f9f1..de6982b 100644 --- a/lib/controls/timeline/status_header_control.dart +++ b/lib/controls/timeline/status_header_control.dart @@ -1,10 +1,11 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_portal/models/timeline_entry.dart'; import 'package:go_router/go_router.dart'; import '../../globals.dart'; import '../../models/connection.dart'; +import '../../models/timeline_entry.dart'; +import '../../routes.dart'; import '../../services/connections_manager.dart'; import '../../utils/dateutils.dart'; import '../../utils/url_opening_utils.dart'; @@ -17,6 +18,10 @@ class StatusHeaderControl extends StatelessWidget { const StatusHeaderControl( {super.key, required this.entry, required this.showActionBar}); + void goToProfile(BuildContext context) { + context.pushNamed(ScreenPaths.userProfile, params: {'id': entry.authorId}); + } + @override Widget build(BuildContext context) { final author = getIt() @@ -26,18 +31,24 @@ class StatusHeaderControl extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - CachedNetworkImage( - imageUrl: author.avatarUrl.toString(), - width: 32.0, + GestureDetector( + onTap: () => goToProfile(context), + child: CachedNetworkImage( + imageUrl: author.avatarUrl.toString(), + width: 32.0, + ), ), const HorizontalPadding(), Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - author.name, - style: Theme.of(context).textTheme.bodyText1, + GestureDetector( + onTap: () => goToProfile(context), + child: Text( + author.name, + style: Theme.of(context).textTheme.bodyText1, + ), ), Row( children: [ @@ -60,7 +71,7 @@ class StatusHeaderControl extends StatelessWidget { ], ), if (showActionBar) ...[ - Expanded(child: SizedBox()), + const Expanded(child: SizedBox()), buildActionBar(context), ], ], @@ -74,7 +85,7 @@ class StatusHeaderControl extends StatelessWidget { onPressed: () async { await _openAction(context); }, - icon: Icon(Icons.launch), + icon: const Icon(Icons.launch), ), ], ); diff --git a/lib/controls/timeline/timeline_panel.dart b/lib/controls/timeline/timeline_panel.dart new file mode 100644 index 0000000..bded5d7 --- /dev/null +++ b/lib/controls/timeline/timeline_panel.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; + +import '../../models/TimelineIdentifiers.dart'; +import '../../services/timeline_manager.dart'; +import 'status_control.dart'; + +class TimelinePanel extends StatelessWidget { + static final _logger = Logger('$TimelinePanel'); + final TimelineIdentifiers timeline; + + const TimelinePanel({super.key, required this.timeline}); + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final result = manager.getTimeline(timeline); + if (result.isFailure) { + return Center(child: Text('Error getting timeline: ${result.error}')); + } + final items = result.value; + print('items count = ${items.length}'); + return RefreshIndicator( + onRefresh: () async { + await manager.updateTimeline(timeline, TimelineRefreshType.refresh); + }, + child: ListView.separated( + itemBuilder: (context, index) { + if (index == 0) { + return TextButton( + onPressed: () async => await manager.updateTimeline( + timeline, TimelineRefreshType.loadNewer), + child: const Text('Load newer posts')); + } + + if (index == items.length + 1) { + return TextButton( + onPressed: () async => await manager.updateTimeline( + timeline, TimelineRefreshType.loadOlder), + child: const Text('Load older posts')); + } + final itemIndex = index - 1; + final item = items[itemIndex]; + _logger.finest( + 'Building item: $itemIndex: ${item.entry.toShortString()}'); + return StatusControl( + originalItem: item, + showActionBar: true, + ); + }, + separatorBuilder: (context, index) => Divider(), + itemCount: items.length + 2, + ), + ); + } +} diff --git a/lib/friendica_client.dart b/lib/friendica_client.dart index 17f49dd..e1c89c9 100644 --- a/lib/friendica_client.dart +++ b/lib/friendica_client.dart @@ -91,7 +91,7 @@ class FriendicaClient { int limit = 20}) async { final String timelinePath = _typeToTimelinePath(type); final String timelineQPs = _typeToTimelineQueryParameters(type); - final baseUrl = 'https://$serverName/api/v1/timelines/$timelinePath'; + final baseUrl = 'https://$serverName/api/v1/$timelinePath'; var pagingData = 'limit=$limit'; if (maxId > 0) { pagingData = '$pagingData&max_id=$maxId'; @@ -104,7 +104,7 @@ class FriendicaClient { final url = '$baseUrl?exclude_replies=true&$pagingData&$timelineQPs'; final request = Uri.parse(url); _logger.finest(() => - 'Getting home timeline limit $limit sinceId: $sinceId maxId: $maxId : $url'); + 'Getting ${type.toHumanKey()} limit $limit sinceId: $sinceId maxId: $maxId : $url'); return (await _getApiListRequest(request).andThenSuccessAsync( (postsJson) async => postsJson .map((json) => TimelineEntryMastodonExtensions.fromJson(json)) @@ -295,13 +295,14 @@ class FriendicaClient { String _typeToTimelinePath(TimelineIdentifiers type) { switch (type.timeline) { case TimelineType.home: - return 'home'; + return 'timelines/home'; case TimelineType.global: - return 'public'; + return 'timelines/public'; case TimelineType.local: - return 'public'; + return 'timelines/public'; case TimelineType.tag: case TimelineType.profile: + return '/accounts/${type.auxData}/statuses'; case TimelineType.self: throw UnimplementedError('These types are not supported yet'); } @@ -311,11 +312,11 @@ class FriendicaClient { switch (type.timeline) { case TimelineType.home: case TimelineType.global: + case TimelineType.profile: return ''; case TimelineType.local: return 'local=true'; case TimelineType.tag: - case TimelineType.profile: case TimelineType.self: throw UnimplementedError('These types are not supported yet'); } diff --git a/lib/models/TimelineIdentifiers.dart b/lib/models/TimelineIdentifiers.dart index c3cd0db..6d633b1 100644 --- a/lib/models/TimelineIdentifiers.dart +++ b/lib/models/TimelineIdentifiers.dart @@ -17,23 +17,29 @@ class TimelineIdentifiers { return auxData.isEmpty ? timeline.name : '${timeline.name}_$auxData'; } - @override - bool operator ==(Object other) => - identical(this, other) || - other is TimelineIdentifiers && - runtimeType == other.runtimeType && - timeline == other.timeline; - - @override - int get hashCode => timeline.hashCode; - factory TimelineIdentifiers.home() => TimelineIdentifiers(timeline: TimelineType.home); + factory TimelineIdentifiers.profile(String profileId) => TimelineIdentifiers( + timeline: TimelineType.profile, + auxData: profileId, + ); + @override String toString() { return auxData.isEmpty ? 'TimelineIdentifiers{timeline: $timeline)' : 'TimelineIdentifiers{timeline: $timeline, auxData: $auxData}'; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TimelineIdentifiers && + runtimeType == other.runtimeType && + timeline == other.timeline && + auxData == other.auxData; + + @override + int get hashCode => timeline.hashCode ^ auxData.hashCode; } diff --git a/lib/routes.dart b/lib/routes.dart index c42ee07..850760e 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -8,6 +8,8 @@ import 'screens/post_screen.dart'; import 'screens/profile_screen.dart'; import 'screens/sign_in.dart'; import 'screens/splash.dart'; +import 'screens/user_posts_screen.dart'; +import 'screens/user_profile_screen.dart'; import 'services/auth_service.dart'; class ScreenPaths { @@ -18,6 +20,8 @@ class ScreenPaths { static String signin = '/signin'; static String signup = '/signup'; static String settings = '/settings'; + static String userProfile = '/user_profile'; + static String userPosts = '/user_posts'; } bool needAuthChangeInitialized = true; @@ -130,4 +134,16 @@ final appRouter = GoRouter( EditorScreen(id: state.params['id'] ?? 'Not Found'), ), ]), + GoRoute( + path: '/user_posts/:id', + name: ScreenPaths.userPosts, + builder: (context, state) => + UserPostsScreen(userId: state.params['id']!), + ), + GoRoute( + path: '/user_profile/:id', + name: ScreenPaths.userProfile, + builder: (context, state) => + UserProfileScreen(userId: state.params['id']!), + ), ]); diff --git a/lib/screens/home.dart b/lib/screens/home.dart index dfb9d01..ea0a56f 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; -import 'package:provider/provider.dart'; import '../controls/app_bottom_nav_bar.dart'; -import '../controls/timeline/status_control.dart'; +import '../controls/timeline/timeline_panel.dart'; import '../models/TimelineIdentifiers.dart'; -import '../services/timeline_manager.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -29,7 +27,6 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { _logger.finest('Build'); - final tm = context.watch(); return Scaffold( appBar: AppBar( title: Text('Home'), @@ -57,55 +54,17 @@ class _HomeScreenState extends State { currentType = value!; }); }), - Expanded(child: buildTimelineComponent(context, tm)) + Expanded( + child: TimelinePanel( + timeline: TimelineIdentifiers( + timeline: currentType, + ), + )), ], ), - bottomNavigationBar: AppBottomNavBar( + bottomNavigationBar: const AppBottomNavBar( currentButton: NavBarButtons.home, ), ); } - - Widget buildTimelineComponent(BuildContext context, TimelineManager manager) { - final result = - manager.getTimeline(TimelineIdentifiers(timeline: currentType)); - if (result.isFailure) { - return Center(child: Text('Error getting timeline: ${result.error}')); - } - final items = result.value; - print('items count = ${items.length}'); - return RefreshIndicator( - onRefresh: () async { - await manager.updateTimeline( - TimelineIdentifiers.home(), TimelineRefreshType.refresh); - }, - child: ListView.separated( - itemBuilder: (context, index) { - if (index == 0) { - return TextButton( - onPressed: () async => await manager.updateTimeline( - TimelineIdentifiers.home(), TimelineRefreshType.loadNewer), - child: const Text('Load newer posts')); - } - - if (index == items.length + 1) { - return TextButton( - onPressed: () async => await manager.updateTimeline( - TimelineIdentifiers.home(), TimelineRefreshType.loadOlder), - child: const Text('Load older posts')); - } - final itemIndex = index - 1; - final item = items[itemIndex]; - _logger.finest( - 'Building item: $itemIndex: ${item.entry.toShortString()}'); - return StatusControl( - originalItem: item, - showActionBar: true, - ); - }, - separatorBuilder: (context, index) => Divider(), - itemCount: items.length + 2, - ), - ); - } } diff --git a/lib/screens/user_posts_screen.dart b/lib/screens/user_posts_screen.dart new file mode 100644 index 0000000..e558cbf --- /dev/null +++ b/lib/screens/user_posts_screen.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../controls/timeline/timeline_panel.dart'; +import '../models/TimelineIdentifiers.dart'; +import '../routes.dart'; + +class UserPostsScreen extends StatelessWidget { + final String userId; + + const UserPostsScreen({super.key, required this.userId}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('User Posts'), + actions: [ + IconButton( + onPressed: () { + Navigator.of(context).popUntil((route) { + return route.settings.name == ScreenPaths.home; + }); + }, + icon: const Icon(Icons.home), + ), + ], + ), + body: Center( + child: TimelinePanel( + timeline: TimelineIdentifiers.profile(userId), + ), + ), + ); + } +} diff --git a/lib/screens/user_profile_screen.dart b/lib/screens/user_profile_screen.dart new file mode 100644 index 0000000..55d09f5 --- /dev/null +++ b/lib/screens/user_profile_screen.dart @@ -0,0 +1,66 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../globals.dart'; +import '../models/connection.dart'; +import '../routes.dart'; +import '../services/connections_manager.dart'; +import '../utils/url_opening_utils.dart'; + +class UserProfileScreen extends StatelessWidget { + final String userId; + + const UserProfileScreen({super.key, required this.userId}); + + Future openProfileExternal( + BuildContext context, + Connection connection, + ) async { + final openInBrowser = + await showYesNoDialog(context, 'Open profile in browser?'); + if (openInBrowser == true) { + await openUrlStringInSystembrowser( + context, connection.profileUrl.toString(), 'Post'); + } + } + + @override + Widget build(BuildContext context) { + final manager = getIt(); + final body = manager.getById(userId).fold(onSuccess: (profile) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CachedNetworkImage(imageUrl: profile.avatarUrl.toString()), + Text(profile.name), + Text(profile.status.toString()), + ElevatedButton( + onPressed: () => context.pushNamed( + ScreenPaths.userPosts, + params: {'id': profile.id}, + ), + child: const Text('Posts')), + ElevatedButton( + onPressed: () async => await openProfileExternal(context, profile), + child: const Text('Open In Browser'), + ), + ], + ); + }, onError: (error) { + return Text('Error getting profile: $error'); + }); + return Scaffold( + appBar: AppBar( + title: Text('Profile'), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: body, + ), + ), + ); + } +}