Add user profile and posts pages first cuts

This commit is contained in:
Hank Grabowski 2022-11-29 15:33:16 +00:00
parent ff3e938f70
commit 6f6fe79ac0
8 changed files with 225 additions and 74 deletions

View file

@ -1,10 +1,11 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_portal/models/timeline_entry.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../globals.dart'; import '../../globals.dart';
import '../../models/connection.dart'; import '../../models/connection.dart';
import '../../models/timeline_entry.dart';
import '../../routes.dart';
import '../../services/connections_manager.dart'; import '../../services/connections_manager.dart';
import '../../utils/dateutils.dart'; import '../../utils/dateutils.dart';
import '../../utils/url_opening_utils.dart'; import '../../utils/url_opening_utils.dart';
@ -17,6 +18,10 @@ class StatusHeaderControl extends StatelessWidget {
const StatusHeaderControl( const StatusHeaderControl(
{super.key, required this.entry, required this.showActionBar}); {super.key, required this.entry, required this.showActionBar});
void goToProfile(BuildContext context) {
context.pushNamed(ScreenPaths.userProfile, params: {'id': entry.authorId});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final author = getIt<ConnectionsManager>() final author = getIt<ConnectionsManager>()
@ -26,19 +31,25 @@ class StatusHeaderControl extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CachedNetworkImage( GestureDetector(
onTap: () => goToProfile(context),
child: CachedNetworkImage(
imageUrl: author.avatarUrl.toString(), imageUrl: author.avatarUrl.toString(),
width: 32.0, width: 32.0,
), ),
),
const HorizontalPadding(), const HorizontalPadding(),
Column( Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( GestureDetector(
onTap: () => goToProfile(context),
child: Text(
author.name, author.name,
style: Theme.of(context).textTheme.bodyText1, style: Theme.of(context).textTheme.bodyText1,
), ),
),
Row( Row(
children: [ children: [
Text( Text(
@ -60,7 +71,7 @@ class StatusHeaderControl extends StatelessWidget {
], ],
), ),
if (showActionBar) ...[ if (showActionBar) ...[
Expanded(child: SizedBox()), const Expanded(child: SizedBox()),
buildActionBar(context), buildActionBar(context),
], ],
], ],
@ -74,7 +85,7 @@ class StatusHeaderControl extends StatelessWidget {
onPressed: () async { onPressed: () async {
await _openAction(context); await _openAction(context);
}, },
icon: Icon(Icons.launch), icon: const Icon(Icons.launch),
), ),
], ],
); );

View file

@ -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<TimelineManager>();
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,
),
);
}
}

View file

@ -91,7 +91,7 @@ class FriendicaClient {
int limit = 20}) async { int limit = 20}) async {
final String timelinePath = _typeToTimelinePath(type); final String timelinePath = _typeToTimelinePath(type);
final String timelineQPs = _typeToTimelineQueryParameters(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'; var pagingData = 'limit=$limit';
if (maxId > 0) { if (maxId > 0) {
pagingData = '$pagingData&max_id=$maxId'; pagingData = '$pagingData&max_id=$maxId';
@ -104,7 +104,7 @@ class FriendicaClient {
final url = '$baseUrl?exclude_replies=true&$pagingData&$timelineQPs'; final url = '$baseUrl?exclude_replies=true&$pagingData&$timelineQPs';
final request = Uri.parse(url); final request = Uri.parse(url);
_logger.finest(() => _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( return (await _getApiListRequest(request).andThenSuccessAsync(
(postsJson) async => postsJson (postsJson) async => postsJson
.map((json) => TimelineEntryMastodonExtensions.fromJson(json)) .map((json) => TimelineEntryMastodonExtensions.fromJson(json))
@ -295,13 +295,14 @@ class FriendicaClient {
String _typeToTimelinePath(TimelineIdentifiers type) { String _typeToTimelinePath(TimelineIdentifiers type) {
switch (type.timeline) { switch (type.timeline) {
case TimelineType.home: case TimelineType.home:
return 'home'; return 'timelines/home';
case TimelineType.global: case TimelineType.global:
return 'public'; return 'timelines/public';
case TimelineType.local: case TimelineType.local:
return 'public'; return 'timelines/public';
case TimelineType.tag: case TimelineType.tag:
case TimelineType.profile: case TimelineType.profile:
return '/accounts/${type.auxData}/statuses';
case TimelineType.self: case TimelineType.self:
throw UnimplementedError('These types are not supported yet'); throw UnimplementedError('These types are not supported yet');
} }
@ -311,11 +312,11 @@ class FriendicaClient {
switch (type.timeline) { switch (type.timeline) {
case TimelineType.home: case TimelineType.home:
case TimelineType.global: case TimelineType.global:
case TimelineType.profile:
return ''; return '';
case TimelineType.local: case TimelineType.local:
return 'local=true'; return 'local=true';
case TimelineType.tag: case TimelineType.tag:
case TimelineType.profile:
case TimelineType.self: case TimelineType.self:
throw UnimplementedError('These types are not supported yet'); throw UnimplementedError('These types are not supported yet');
} }

View file

@ -17,23 +17,29 @@ class TimelineIdentifiers {
return auxData.isEmpty ? timeline.name : '${timeline.name}_$auxData'; 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() => factory TimelineIdentifiers.home() =>
TimelineIdentifiers(timeline: TimelineType.home); TimelineIdentifiers(timeline: TimelineType.home);
factory TimelineIdentifiers.profile(String profileId) => TimelineIdentifiers(
timeline: TimelineType.profile,
auxData: profileId,
);
@override @override
String toString() { String toString() {
return auxData.isEmpty return auxData.isEmpty
? 'TimelineIdentifiers{timeline: $timeline)' ? 'TimelineIdentifiers{timeline: $timeline)'
: 'TimelineIdentifiers{timeline: $timeline, auxData: $auxData}'; : '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;
} }

View file

@ -8,6 +8,8 @@ import 'screens/post_screen.dart';
import 'screens/profile_screen.dart'; import 'screens/profile_screen.dart';
import 'screens/sign_in.dart'; import 'screens/sign_in.dart';
import 'screens/splash.dart'; import 'screens/splash.dart';
import 'screens/user_posts_screen.dart';
import 'screens/user_profile_screen.dart';
import 'services/auth_service.dart'; import 'services/auth_service.dart';
class ScreenPaths { class ScreenPaths {
@ -18,6 +20,8 @@ class ScreenPaths {
static String signin = '/signin'; static String signin = '/signin';
static String signup = '/signup'; static String signup = '/signup';
static String settings = '/settings'; static String settings = '/settings';
static String userProfile = '/user_profile';
static String userPosts = '/user_posts';
} }
bool needAuthChangeInitialized = true; bool needAuthChangeInitialized = true;
@ -130,4 +134,16 @@ final appRouter = GoRouter(
EditorScreen(id: state.params['id'] ?? 'Not Found'), 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']!),
),
]); ]);

View file

@ -1,12 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../controls/app_bottom_nav_bar.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 '../models/TimelineIdentifiers.dart';
import '../services/timeline_manager.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@ -29,7 +27,6 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_logger.finest('Build'); _logger.finest('Build');
final tm = context.watch<TimelineManager>();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Home'), title: Text('Home'),
@ -57,55 +54,17 @@ class _HomeScreenState extends State<HomeScreen> {
currentType = value!; currentType = value!;
}); });
}), }),
Expanded(child: buildTimelineComponent(context, tm)) Expanded(
child: TimelinePanel(
timeline: TimelineIdentifiers(
timeline: currentType,
),
)),
], ],
), ),
bottomNavigationBar: AppBottomNavBar( bottomNavigationBar: const AppBottomNavBar(
currentButton: NavBarButtons.home, 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,
),
);
}
} }

View file

@ -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),
),
),
);
}
}

View file

@ -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<void> 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<ConnectionsManager>();
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,
),
),
);
}
}