Merge branch 'prep-v0.2.0' into 'main'

Prep v0.2.0

See merge request mysocialportal/relatica!22
This commit is contained in:
HankG 2023-03-15 17:40:14 +00:00
commit e7c99b5021
21 changed files with 359 additions and 313 deletions

View file

@ -1,6 +1,33 @@
# Relatica Change Log # Relatica Change Log
## Version 0.1.0b3 (beta) ## Version 0.2.0 (beta), 15 March 2023
* Changes
* Uses Google's Material 3 UI instead of Material 2
* Drawer idiom replaces the menu screen idiom. Drawer contains list of logged in accounts at the top to quickly
switch between, followed by the menu items previously on the menu screen.
* "Sign In Screen" now also the "Manage Accounts" screen
* Username/password settings split the username/servername field so can use the email address as the login like
with
the website
* Have sections listing logged in and logged out accounts.
* Can permanently delete previous credentials if no longer want them.
* Added back swiping down for refresh on timelines, notifications, and contacts. Also changed the progress indicator
to be a linear bar at the top
* Unread notifications counts no longer appear.
* "New Post" button is now a floating action button at the bottom right of the timeline
* Fixes
* Toggle like status on nested comments no longer generates an error.
* New Features
* Support for OAuth Login as well as username/password
* Support for multiple concurrent logins and switching between them
* Added paging to notifications
* Ability to list who liked or reshared a post by clicking on the respective summary.
* Unread direct messages indicators show up in the notifications panel
* Data Pages manager allows for re-requesting data from server based on previous calls. Currently used only for
notifications.
## Version 0.1.0b3 (beta), 30 January 2023
* Changes * Changes
* The timeline names have been changed to: * The timeline names have been changed to:
@ -17,6 +44,8 @@
* Keyboard type on login screen for username field is now "email" type * Keyboard type on login screen for username field is now "email" type
* New Features * New Features
* A combined network status/refresh icon on timeline, notifications, and gallery panel * A combined network status/refresh icon on timeline, notifications, and gallery panel
* More detailed user profile screen for users which now includes things like their description, handle and common
name, etc.
* Paging in gallery list to incremental load of larger galleries (also sorted newest first) * Paging in gallery list to incremental load of larger galleries (also sorted newest first)
* Image View Screen has a Carousel feature for swiping through images of a post, gallery, etc. * Image View Screen has a Carousel feature for swiping through images of a post, gallery, etc.
* Can copy post/comment Raw HTML text, image descriptions, and Direct Message text content * Can copy post/comment Raw HTML text, image descriptions, and Direct Message text content
@ -25,7 +54,7 @@
* Dialog Box "Menu" on posts/comments to more easily decide to navigate to it internally (Posts only), open in the * Dialog Box "Menu" on posts/comments to more easily decide to navigate to it internally (Posts only), open in the
system's default browser, or copy the URL system's default browser, or copy the URL
## Version 0.1.0b2 (beta) ## Version 0.1.0b2 (beta), 27 January 2023
* Fixes * Fixes
* Scrollwheel zooming now works correctly on desktop * Scrollwheel zooming now works correctly on desktop
@ -47,7 +76,7 @@
* Start a new conversation by searching for a known contact "type @ and start typing like in * Start a new conversation by searching for a known contact "type @ and start typing like in
posts/comments" posts/comments"
## Version 0.1.0 (beta) ## Version 0.1.0 (beta), 20 January 2023
* Initial public release, as an early beta. * Initial public release, as an early beta.
* Major working features (at least in initial implementation versions): * Major working features (at least in initial implementation versions):

View file

@ -2,27 +2,28 @@
A Flutter application for interfacing with the Friendica social network. A Flutter application for interfacing with the Friendica social network.
<img src="screenshots/v.0.1.0/windows/RelaticaFirstScreenshot.png" alt="Relatica v0.1.0 on Windows Screenshot" width="300px"/> <img src="screenshots/v0.2.0/linux/relatica_v0.2.0_home.png" alt="Relatica v0.2.0 on Linux Home Screen Screenshot" width="300px"/>
<img src="screenshots/v0.2.0/linux/relatica_v0.2.0_drawer.png" alt="Relatica v0.2.0 on Linux Home Expanded Drawer Screenshot" width="300px"/>
## Project Objectives ## Project Objectives
* Have a native app client on mobile (Apple and Android) and desktop (Linux, Mac, and Windows) * Have a native app client on mobile (Apple and Android) and desktop (Linux, Mac, and Windows)
* Providing a simpler UX for people to interact with Friendica * Providing a simpler UX for people to interact with Friendica
* Providing a better low-bandwidth environment experience than the web-app version running in a browser * Providing a better low-bandwidth environment experience than the web-app version running in a browser
* Reduce server side loading in the new fediverse era by doing techniques like leveraging paging of comments, local caching, and lazy loading. * Reduce server side loading in the new fediverse era by doing techniques like leveraging paging of comments, local
caching, and lazy loading.
* Provide more intuitive mechanisms for adding things like Content Warning/Spoiler text and image ALT-text * Provide more intuitive mechanisms for adding things like Content Warning/Spoiler text and image ALT-text
## Current Status ## Current Status
The project is currently in an early-beta state. If you'd like to use it at this time please The project is currently in an early-beta state. If you'd like to use it at this time please
see the notes at [Relatica Beta Testing Program](beta-program.md). see the notes at [Relatica Beta Testing Program](beta-program.md).
It is possible to install it now by following the [install instructions](install.md). It is possible to install it now by following the [install instructions](install.md).
If you would like to contribute please see [this Developers Notes](developers.md) section. If you would like to contribute please see [this Developers Notes](developers.md) section.
## Community and Support ## Community and Support
[Relatica Community Matrix Discussion Room](https://matrix.to/#/#relatica-community:myportal.social) [Relatica Community Matrix Discussion Room](https://matrix.to/#/#relatica-community:myportal.social)
@ -30,6 +31,7 @@ If you would like to contribute please see [this Developers Notes](developers.md
[Issue Tracker](https://gitlab.com/mysocialportal/relatica/-/issues) [Issue Tracker](https://gitlab.com/mysocialportal/relatica/-/issues)
### Things I could use help from community on: ### Things I could use help from community on:
* More coders and testers are always welcome * More coders and testers are always welcome
* CI/CD * CI/CD
* Packaging for Linux operating systems using Flatpak, Snap, etc. (or individual packages per operating system) * Packaging for Linux operating systems using Flatpak, Snap, etc. (or individual packages per operating system)
@ -39,4 +41,5 @@ If you would like to contribute please see [this Developers Notes](developers.md
* Spreading the word * Spreading the word
## License ## License
Relatica is licensed with the [Mozilla Public License 2.0 (MPL)](LICENSE) copyleft license. Relatica is licensed with the [Mozilla Public License 2.0 (MPL)](LICENSE) copyleft license.

View file

@ -55,7 +55,8 @@ wanted to lay out some expectations before getting into the small details
### Things that work ### Things that work
* Logging in with username/password. These are stored using the OS specific key vaults * Logging in with username/password and OAuth. These are stored using the OS specific key vaults
* Having multiple accounts logged in at the same time and switching between them
* Writing **public** posts * Writing **public** posts
* Typing @ brings up a list of all known fediverse accounts that the app has ever seen as you * Typing @ brings up a list of all known fediverse accounts that the app has ever seen as you
type (but not all that your server has seen) type (but not all that your server has seen)
@ -84,13 +85,14 @@ wanted to lay out some expectations before getting into the small details
specific buttons (this will probably change in the near future) specific buttons (this will probably change in the near future)
* Refresh notifications and contacts gets updates to that respective data (this may * Refresh notifications and contacts gets updates to that respective data (this may
change in the near future) change in the near future)
* Show list of who liked and reshared posts/comments
* Detailed information about users, such as description fields
### Big things I want to have working in the near future: ### Big things I want to have working in the near future:
* Show list of who liked and reshared posts/comments
* More timeline types like Comments, "By Activity", etc. * More timeline types like Comments, "By Activity", etc.
* Better timeline UX to allow for "in-fill" * Better timeline UX to allow for "in-fill"
* More detailed profile information for users and logged in user * Fine grain details about users (last post, last update)
* Better data display on larger format displays by doing things like: * Better data display on larger format displays by doing things like:
* Allowing images/thumbnails to be larger * Allowing images/thumbnails to be larger
* Limiting maximum width of timeline columns * Limiting maximum width of timeline columns
@ -124,7 +126,6 @@ wanted to lay out some expectations before getting into the small details
* Nitter replacement of Twitter links * Nitter replacement of Twitter links
* User configurable Server blocking * User configurable Server blocking
* Server-side searching tied into profiles, posts, hashtags * Server-side searching tied into profiles, posts, hashtags
* OAuth logins
* Being able to ignore/unignore users * Being able to ignore/unignore users
* Deleting images and entire galleries * Deleting images and entire galleries
* Events * Events
@ -134,7 +135,6 @@ wanted to lay out some expectations before getting into the small details
* Account creation through the application * Account creation through the application
* Creating new forums through the application * Creating new forums through the application
* Site administration * Site administration
* Two-factor authentication
* Adding videos or files to posts * Adding videos or files to posts
* Multi-account logins * Multi-account logins
@ -143,13 +143,13 @@ wanted to lay out some expectations before getting into the small details
### Broken and hopefully fixed in the very near future: ### Broken and hopefully fixed in the very near future:
* Resharing of Diaspora federated posts is currently broken server side. All other posts should be * Resharing of Diaspora federated posts is currently broken server side. All other posts should be
reshareable. (Already fixed in Friendica but not in the stable release yet) reshareable. (Fixed in Friendica 2023.03)
* Content Warnings/Spoiler Text on *posts* aren't federating over to Mastodon well so only use it on * Content Warnings/Spoiler Text on *posts* aren't federating over to Mastodon well so only use it on
Comments for now Comments for now (fixed on servers running Friendica 2023.03)
* ALT text on images should not have any quotation marks as it breaks when federating over to * ALT text on images should not have any quotation marks as it breaks when federating over to
Diaspora for the time being Diaspora for the time being
* Portrait videos overflow their boxes in the timeline * Portrait videos overflow their boxes in the timeline
* Blocked/ignored user's content is still returned by the API * Blocked/ignored user's content is still returned by the API (Fixed in Friendica 2023.03)
* Paging for some of the endpoints either isn't wired in yet or is not working as needed server * Paging for some of the endpoints either isn't wired in yet or is not working as needed server
side. That includes things like: side. That includes things like:
* Friend requests * Friend requests
@ -164,19 +164,12 @@ wanted to lay out some expectations before getting into the small details
* The only post type that is supported right now are public posts therefore all posts you write * The only post type that is supported right now are public posts therefore all posts you write
through the app will have this privacy level. through the app will have this privacy level.
* Notifications need to be manually refreshed. * Notifications need to be manually refreshed.
* Responsiveness can be laggy. Sometimes hitting buttons doesn't seem to do something but it is
doing a network request. I know I need to improve that
* In galleries you need to double click on the picture to open the preview. It is remnants of an
experiment I was doing on more consistent UX which feels broken so I'm changing.
* Sometimes timelines get confused so swapping between the different groups/timelines creates a * Sometimes timelines get confused so swapping between the different groups/timelines creates a
muddled display. Restarting the app fixes this. muddled display. Restarting the app fixes this.
* Some images within posts, usually graphical emojis, are rendered drastically larger than they * Some images within posts, usually graphical emojis, are rendered drastically larger than they
should be. should be.
* On Linux you will need to re-enter your credentials each time you use the app for the first time * On Linux you will need to login to the key manager and unlock it before opening the app. Some Linux versions do this
after logging in. automatically while others do not.
* Groups are listed by the order they were created not alphabetically in the drop down menu
* Liking a nested comment can appear to lock up (it stays grayed out). Navigating back and forth
fixes that.
* The "in fill" problem: Timelines fill only at the ends with at most 20 posts per call. So let's * The "in fill" problem: Timelines fill only at the ends with at most 20 posts per call. So let's
say you logged in at 09:00 and the initial pulls went from 07:00 to 09:00: say you logged in at 09:00 and the initial pulls went from 07:00 to 09:00:
``` ```

View file

@ -6,12 +6,11 @@ For more information about the current beta testing program
# Latest Binaries: # Latest Binaries:
* [Android v0.1.0b3](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.1.0/relatica_v0.1.0b3.apk.zip) * [Android v0.2.0](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.2.0%2Frelatica_v0.2.0.apk.zip)
* iPhone/iPad v0.1.0b3: This is only available through TestFlight. Please contact me for access. * iPhone/iPad v0.2.0: This is only available through TestFlight. Please contact me for access.
* [Windows (Intel) v0.1.0b3](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.1.0/Relatica_v0.1.0b3_x64_win.zip) * [Windows (Intel) v0.2.0](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.2.0%2FRelatica_v0.2.0_x64_win.zip)
* [macOS v0.1.0b3](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.1.0/Relatica_v0.1.0b3_mac.zip) * macOS v0.2.0 This is only available through TestFlight. Please contact me for access.
also through TestFlight * [Linux (Intel Ubuntu 22) v0.2.0](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.2.0%2Frelatica_v0.2.0_linux_x64_ubuntu22.zip)
* [Linux (Intel Ubuntu 22) v0.1.0b3](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.1.0/relatica_v0.1.0b3_linux_x64_ubuntu22.zip)
## Mobile ## Mobile

View file

@ -42,12 +42,7 @@ class AppBottomNavBar extends StatelessWidget {
switch (newButton) { switch (newButton) {
case NavBarButtons.timelines: case NavBarButtons.timelines:
try { context.go(ScreenPaths.timelines);
Navigator.of(context)
.popUntil(ModalRoute.withName(ScreenPaths.timelines));
} catch (e) {
context.go(ScreenPaths.timelines);
}
break; break;
case NavBarButtons.notifications: case NavBarButtons.notifications:
context.pushNamed(ScreenPaths.notifications); context.pushNamed(ScreenPaths.notifications);

View file

@ -9,64 +9,66 @@ import '../services/auth_service.dart';
class StandardAppDrawer extends StatelessWidget { class StandardAppDrawer extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Drawer( return SafeArea(
child: ListView( child: Drawer(
padding: EdgeInsets.zero, child: ListView(
children: [ padding: EdgeInsets.zero,
...getIt<AccountsService>().loggedInProfiles.map( children: [
(p) => ListTile( ...getIt<AccountsService>().loggedInProfiles.map(
onTap: () async { (p) => ListTile(
await getIt<AccountsService>().setActiveProfile(p); onTap: () async {
if (context.mounted) { await getIt<AccountsService>().setActiveProfile(p);
context.pop(); if (context.mounted && context.canPop()) {
} context.pop();
}, }
leading: CircleAvatar( },
child: CachedNetworkImage(imageUrl: p.avatar)), leading: CircleAvatar(
title: Text( child: CachedNetworkImage(imageUrl: p.avatar)),
p.username, title: Text(
style: p == getIt<AccountsService>().currentProfile p.username,
? TextStyle(fontWeight: FontWeight.bold) style: p == getIt<AccountsService>().currentProfile
: null, ? TextStyle(fontWeight: FontWeight.bold)
), : null,
subtitle: Text( ),
p.serverName, subtitle: Text(
style: p == getIt<AccountsService>().currentProfile p.serverName,
? TextStyle(fontWeight: FontWeight.bold) style: p == getIt<AccountsService>().currentProfile
: null, ? TextStyle(fontWeight: FontWeight.bold)
: null,
),
), ),
), ),
), buildMenuButton(
buildMenuButton( context,
context, 'Manage Profiles',
'Manage Profiles', () => context.pushNamed(ScreenPaths.manageProfiles),
() => context.pushNamed(ScreenPaths.manageProfiles), ),
), const Divider(),
const Divider(), buildMenuButton(
buildMenuButton( context,
context, 'Gallery',
'Gallery', () => context.pushNamed(ScreenPaths.gallery),
() => context.pushNamed(ScreenPaths.gallery), ),
), buildMenuButton(
buildMenuButton( context,
context, 'Direct Messages',
'Direct Messages', () => context.pushNamed(ScreenPaths.messages),
() => context.pushNamed(ScreenPaths.messages), ),
), buildMenuButton(
buildMenuButton( context,
context, 'Settings',
'Settings', () => context.pushNamed(ScreenPaths.settings),
() => context.pushNamed(ScreenPaths.settings), ),
), // TODO Add back in clearing ability but has to do disk caches too
// TODO Add back in clearing ability but has to do disk caches too // buildMenuButton(context, 'Clear Caches', () async {
// buildMenuButton(context, 'Clear Caches', () async { // final confirm = await showYesNoDialog(
// final confirm = await showYesNoDialog( // context, 'You want to clear all memory and disk cache data?');
// context, 'You want to clear all memory and disk cache data?'); // if (confirm == true) {
// if (confirm == true) { // clearCaches();
// clearCaches(); // }
// } // }),
// }), ],
], ),
), ),
); );
} }

View file

@ -24,35 +24,45 @@ class TimelinePanel extends StatelessWidget {
return Center(child: Text('Error getting timeline: ${result.error}')); return Center(child: Text('Error getting timeline: ${result.error}'));
} }
final items = result.value; final items = result.value;
return ListView.separated( return RefreshIndicator(
itemBuilder: (context, index) { onRefresh: () async {
if (index == 0) { manager.updateTimeline(
return TextButton( timeline,
onPressed: () async => await manager.updateTimeline( TimelineRefreshType.refresh,
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 PostControl(
originalItem: item,
scrollToId: item.id,
openRemote: false,
showStatusOpenButton: true,
isRoot: false,
); );
return;
}, },
separatorBuilder: (context, index) => Divider(), child: ListView.separated(
itemCount: items.length + 2, physics: const AlwaysScrollableScrollPhysics(),
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 PostControl(
originalItem: item,
scrollToId: item.id,
openRemote: false,
showStatusOpenButton: true,
isRoot: false,
);
},
separatorBuilder: (context, index) => Divider(),
itemCount: items.length + 2,
),
); );
} }
} }

View file

@ -17,7 +17,11 @@ class ObjectBoxCache {
final docsDir = await getApplicationSupportDirectory(); final docsDir = await getApplicationSupportDirectory();
final path = p.join(docsDir.path, baseDir, subDir); final path = p.join(docsDir.path, baseDir, subDir);
Directory(path).createSync(recursive: true); try {
Directory(path).createSync(recursive: true);
} catch (e) {
_logger.severe('Error creating ObjectCachePathDirectory: $e');
}
_logger.info('ObjectBoxCache path: $path'); _logger.info('ObjectBoxCache path: $path');
final store = await openStore( final store = await openStore(
directory: path, macosApplicationGroup: 'T69YZGT58U.relatica'); directory: path, macosApplicationGroup: 'T69YZGT58U.relatica');

View file

@ -87,7 +87,7 @@ Future<void> dependencyInjectionInitialization() async {
Future<void> updateProfileDependencyInjectors(Profile profile) async { Future<void> updateProfileDependencyInjectors(Profile profile) async {
final objectBox = await ObjectBoxCache.create( final objectBox = await ObjectBoxCache.create(
baseDir: 'profileboxcaches', baseDir: 'profileboxcaches',
subDir: '${profile.username}_${profile.serverName}', subDir: '${profile.id}_${profile.serverName}',
); );
final connectionReposSelector = final connectionReposSelector =
getIt<ActiveProfileSelector<IConnectionsRepo>>(); getIt<ActiveProfileSelector<IConnectionsRepo>>();

View file

@ -41,8 +41,10 @@ class PagesManager<TResult, TID> {
} }
final result = await onRequest(PagingData(limit: limit)); final result = await onRequest(PagingData(limit: limit));
if (result.isSuccess) { if (result.isSuccess) {
final newPage = result.value.map((data) => idMapper(data)); if (result.value.previous != null || result.value.next != null) {
_pages.add(newPage); final newPage = result.value.map((data) => idMapper(data));
_pages.add(newPage);
}
} }
return result; return result;
} }

View file

@ -5,9 +5,7 @@ import 'package:provider/provider.dart';
import '../controls/app_bottom_nav_bar.dart'; import '../controls/app_bottom_nav_bar.dart';
import '../controls/current_profile_button.dart'; import '../controls/current_profile_button.dart';
import '../controls/padding.dart';
import '../controls/standard_app_drawer.dart'; import '../controls/standard_app_drawer.dart';
import '../controls/status_and_refresh_button.dart';
import '../globals.dart'; import '../globals.dart';
import '../models/connection.dart'; import '../models/connection.dart';
import '../routes.dart'; import '../routes.dart';
@ -45,10 +43,11 @@ class _ContactsScreenState extends State<ContactsScreen> {
); );
late Widget body; late Widget body;
if (contacts.isEmpty) { if (contacts.isEmpty) {
body = const SingleChildScrollView( body = SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(), physics: AlwaysScrollableScrollPhysics(),
child: Text('No Contacts'), child: Center(
); child: Text('No contacts'),
));
} else { } else {
body = ListView.separated( body = ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@ -66,67 +65,58 @@ class _ContactsScreenState extends State<ContactsScreen> {
separatorBuilder: (context, index) => const Divider(), separatorBuilder: (context, index) => const Divider(),
itemCount: contacts.length); itemCount: contacts.length);
} }
final profileButton = buildCurrentProfileButton(context);
return Scaffold( return SafeArea(
drawer: StandardAppDrawer(), child: Scaffold(
body: SafeArea( appBar: AppBar(
child: RefreshIndicator( leading: buildCurrentProfileButton(context),
onRefresh: () async { title: TextField(
if (nss.connectionUpdateStatus.value) { onChanged: (value) {
return; setState(() {
} filterText = value.toLowerCase();
await manager.updateAllContacts(); });
}, },
child: Column( decoration: InputDecoration(
mainAxisAlignment: MainAxisAlignment.start, labelText: 'Filter By Name',
crossAxisAlignment: CrossAxisAlignment.start, alignLabelWithHint: true,
children: [ border: OutlineInputBorder(
Padding( borderSide: BorderSide(
padding: const EdgeInsets.all(8.0), color: Theme.of(context).backgroundColor,
child: Row(
children: [
if (profileButton != null) ...[
SizedBox(width: 70.0, child: profileButton),
const HorizontalPadding(),
],
Expanded(
child: TextField(
onChanged: (value) {
setState(() {
filterText = value.toLowerCase();
});
},
decoration: InputDecoration(
labelText: 'Filter By Name',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).backgroundColor,
),
borderRadius: BorderRadius.circular(5.0),
),
),
),
),
const HorizontalPadding(),
StatusAndRefreshButton(
valueListenable: nss.connectionUpdateStatus,
refreshFunction: () async =>
await manager.updateAllContacts(),
)
],
), ),
borderRadius: BorderRadius.circular(5.0),
), ),
const VerticalPadding(), ),
Expanded(
child: body,
),
],
), ),
), ),
), drawer: StandardAppDrawer(),
bottomNavigationBar: AppBottomNavBar( body: SafeArea(
currentButton: NavBarButtons.contacts, child: RefreshIndicator(
onRefresh: () async {
if (nss.connectionUpdateStatus.value) {
return;
}
manager.updateAllContacts();
return;
},
child: Column(
children: [
ValueListenableBuilder(
valueListenable: nss.connectionUpdateStatus,
builder: (context2, executing, _) {
if (executing) {
return const LinearProgressIndicator();
}
return const SizedBox();
}),
Expanded(child: body),
],
),
),
),
bottomNavigationBar: AppBottomNavBar(
currentButton: NavBarButtons.contacts,
),
), ),
); );
} }

View file

@ -7,7 +7,6 @@ import 'package:provider/provider.dart';
import '../controls/app_bottom_nav_bar.dart'; import '../controls/app_bottom_nav_bar.dart';
import '../controls/padding.dart'; import '../controls/padding.dart';
import '../controls/standard_app_drawer.dart'; import '../controls/standard_app_drawer.dart';
import '../controls/status_and_refresh_button.dart';
import '../controls/timeline/timeline_panel.dart'; import '../controls/timeline/timeline_panel.dart';
import '../globals.dart'; import '../globals.dart';
import '../models/TimelineIdentifiers.dart'; import '../models/TimelineIdentifiers.dart';
@ -57,81 +56,95 @@ class _HomeScreenState extends State<HomeScreen> {
auxData: currentGroup?.id ?? '', auxData: currentGroup?.id ?? '',
); );
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: accountService.loggedIn leading: accountService.loggedIn
? Builder(builder: (context) { ? Builder(builder: (context) {
return IconButton( return IconButton(
onPressed: () { onPressed: () {
Scaffold.of(context).openDrawer(); Scaffold.of(context).openDrawer();
},
icon: CachedNetworkImage(
imageUrl: accountService.currentProfile.avatar));
})
: null,
backgroundColor: Theme.of(context).canvasColor,
title: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
if (currentType == TimelineType.group)
PopupMenuButton<TimelineType>(
initialValue: currentType,
// Callback that sets the selected popup menu item.
onSelected: (value) {
setState(() {
currentType = value;
});
}, },
icon: CachedNetworkImage( itemBuilder: (BuildContext context) => types
imageUrl: accountService.currentProfile.avatar)); .map((e) => PopupMenuItem<TimelineType>(
}) value: e,
: null, child: Text(e.toLabel()),
backgroundColor: Theme.of(context).canvasColor, ))
title: Row( .toList()),
mainAxisAlignment: MainAxisAlignment.start, if (currentType != TimelineType.group)
children: [ DropdownButton<TimelineType>(
DropdownButton<TimelineType>( value: currentType,
value: currentType, items: types
items: types .map((e) => DropdownMenuItem<TimelineType>(
.map((e) => DropdownMenuItem<TimelineType>( value: e,
value: e, child: Text(e.toLabel()),
child: Text(e.toLabel()), ))
)) .toList(),
.toList(), onChanged: (value) {
onChanged: (value) { setState(() {
setState(() { currentType = value!;
currentType = value!; });
}); }),
}), const HorizontalPadding(
const HorizontalPadding(), width: 5.0,
if (currentType == TimelineType.group) ),
DropdownButton<GroupData>( if (currentType == TimelineType.group)
value: currentGroup, DropdownButton<GroupData>(
items: groups value: currentGroup,
.map((g) => DropdownMenuItem<GroupData>( items: groups
value: g, .map((g) => DropdownMenuItem<GroupData>(
child: Text(g.name), value: g,
)) child: Text(g.name),
.toList(), ))
onChanged: (value) { .toList(),
setState(() { onChanged: (value) {
currentGroup = value; setState(() {
}); currentGroup = value;
}), });
], }),
],
),
), ),
actions: [ body: Center(
StatusAndRefreshButton( child: Column(
valueListenable: nss.timelineLoadingStatus, children: [
refreshFunction: () async => await manager.updateTimeline( ValueListenableBuilder(
currentTimeline, TimelineRefreshType.refresh), valueListenable: nss.timelineLoadingStatus,
buttonColor: Theme.of(context).textTheme.bodyLarge?.color, builder: (context2, executing, _) {
if (executing) {
return const LinearProgressIndicator();
}
return const SizedBox();
}),
Expanded(child: TimelinePanel(timeline: currentTimeline)),
],
), ),
IconButton( ),
onPressed: () { drawer: StandardAppDrawer(),
context.push('/post/new'); bottomNavigationBar: const AppBottomNavBar(
}, currentButton: NavBarButtons.timelines,
icon: Icon( ),
Icons.edit, floatingActionButton: FloatingActionButton.small(
color: Theme.of(context).textTheme.bodyLarge?.color, onPressed: () {
), context.push('/post/new');
), },
], child: Icon(Icons.add),
), ));
body: Column(
children: [
Expanded(
child: TimelinePanel(
timeline: currentTimeline,
)),
],
),
drawer: StandardAppDrawer(),
bottomNavigationBar: const AppBottomNavBar(
currentButton: NavBarButtons.timelines,
),
);
} }
} }

View file

@ -34,7 +34,6 @@ class NotificationsScreen extends StatelessWidget {
StatusAndRefreshButton( StatusAndRefreshButton(
valueListenable: nss.notificationsUpdateStatus, valueListenable: nss.notificationsUpdateStatus,
refreshFunction: () async => manager.updateNotifications(), refreshFunction: () async => manager.updateNotifications(),
busyColor: Theme.of(context).colorScheme.background,
), ),
IconButton( IconButton(
onPressed: () async => _clearAllNotifications(context, manager), onPressed: () async => _clearAllNotifications(context, manager),
@ -42,7 +41,6 @@ class NotificationsScreen extends StatelessWidget {
), ),
]; ];
if (notifications.isEmpty) { if (notifications.isEmpty) {
manager.updateNotifications();
title = 'Notifications'; title = 'Notifications';
body = Center( body = Center(
child: Column( child: Column(
@ -52,12 +50,15 @@ class NotificationsScreen extends StatelessWidget {
)); ));
} else { } else {
final unreadCount = notifications.where((e) => !e.dismissed).length; final unreadCount = notifications.where((e) => !e.dismissed).length;
title = 'Notifications ($unreadCount)'; title =
'Notifications'; //TODO wire in the summary count data if has that endpoint
body = RefreshIndicator( body = RefreshIndicator(
onRefresh: () async { onRefresh: () async {
manager.updateNotifications(); manager.updateNotifications();
return;
}, },
child: ListView.separated( child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0) { if (index == 0) {
return TextButton( return TextButton(

View file

@ -136,6 +136,7 @@ class _SignInScreenState extends State<SignInScreen> {
), ),
const VerticalPadding(), const VerticalPadding(),
TextFormField( TextFormField(
autocorrect: false,
readOnly: existingAccount, readOnly: existingAccount,
autovalidateMode: AutovalidateMode.onUserInteraction, autovalidateMode: AutovalidateMode.onUserInteraction,
controller: serverNameController, controller: serverNameController,

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relatica/utils/active_profile_selector.dart';
import '../controls/padding.dart'; import '../controls/padding.dart';
import '../globals.dart'; import '../globals.dart';
@ -40,7 +41,10 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final manager = context.watch<ConnectionsManager>(); final manager = context
.watch<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry
.value;
final body = manager.getById(widget.userId).fold(onSuccess: (profile) { final body = manager.getById(widget.userId).fold(onSuccess: (profile) {
final notMyProfile = final notMyProfile =
getIt<AccountsService>().currentProfile.userId != profile.id; getIt<AccountsService>().currentProfile.userId != profile.id;

View file

@ -14,10 +14,12 @@ import 'auth_service.dart';
class DirectMessageService extends ChangeNotifier { class DirectMessageService extends ChangeNotifier {
static final _logger = Logger('$DirectMessageService'); static final _logger = Logger('$DirectMessageService');
final _threads = <String, DirectMessageThread>{}; final _threads = <String, DirectMessageThread>{};
var _firstLoading = true;
List<DirectMessageThread> getThreads({bool unreadyOnly = false}) { List<DirectMessageThread> getThreads({bool unreadyOnly = false}) {
if (_threads.isEmpty) { if (_threads.isEmpty && _firstLoading) {
updateThreads(); updateThreads();
_firstLoading = false;
} }
if (unreadyOnly) { if (unreadyOnly) {

View file

@ -68,8 +68,7 @@ class EntryManagerService extends ChangeNotifier {
FutureResult<bool, ExecError> deleteEntryById(String id) async { FutureResult<bool, ExecError> deleteEntryById(String id) async {
_logger.finest('Delete entry: $id'); _logger.finest('Delete entry: $id');
final result = final result = await StatusesClient(getIt<AccountsService>().currentProfile)
await StatusesClient(getIt<AccountsService>().currentProfile)
.deleteEntryById(id); .deleteEntryById(id);
if (result.isFailure) { if (result.isFailure) {
return result.errorCast(); return result.errorCast();
@ -80,7 +79,8 @@ class EntryManagerService extends ChangeNotifier {
return Result.ok(true); return Result.ok(true);
} }
FutureResult<bool, ExecError> createNewStatus(String text, { FutureResult<bool, ExecError> createNewStatus(
String text, {
String spoilerText = '', String spoilerText = '',
String inReplyToId = '', String inReplyToId = '',
required NewEntryMediaItems mediaItems, required NewEntryMediaItems mediaItems,
@ -108,12 +108,12 @@ class EntryManagerService extends ChangeNotifier {
} }
final uploadResult = final uploadResult =
await MediaUploadAttachmentHelper.getUploadableImageBytes( await MediaUploadAttachmentHelper.getUploadableImageBytes(
item.localFilePath, item.localFilePath,
).andThenAsync( ).andThenAsync(
(imageBytes) async => (imageBytes) async =>
await RemoteFileClient(getIt<AccountsService>().currentProfile) await RemoteFileClient(getIt<AccountsService>().currentProfile)
.uploadFileAsAttachment( .uploadFileAsAttachment(
bytes: imageBytes, bytes: imageBytes,
album: mediaItems.albumName, album: mediaItems.albumName,
description: item.description, description: item.description,
@ -129,13 +129,12 @@ class EntryManagerService extends ChangeNotifier {
} }
} }
final result = final result = await StatusesClient(getIt<AccountsService>().currentProfile)
await StatusesClient(getIt<AccountsService>().currentProfile)
.createNewStatus( .createNewStatus(
text: text, text: text,
spoilerText: spoilerText, spoilerText: spoilerText,
inReplyToId: inReplyToId, inReplyToId: inReplyToId,
mediaIds: mediaIds) mediaIds: mediaIds)
.andThenSuccessAsync((item) async { .andThenSuccessAsync((item) async {
await processNewItems( await processNewItems(
[item], getIt<AccountsService>().currentProfile.username, null); [item], getIt<AccountsService>().currentProfile.username, null);
@ -157,7 +156,7 @@ class EntryManagerService extends ChangeNotifier {
_logger.finest('${status.id} status created'); _logger.finest('${status.id} status created');
return true; return true;
}).mapError( }).mapError(
(error) { (error) {
_logger.finest('Error creating post: $error'); _logger.finest('Error creating post: $error');
return ExecError( return ExecError(
type: ErrorType.localError, type: ErrorType.localError,
@ -184,12 +183,10 @@ class EntryManagerService extends ChangeNotifier {
} }
itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id)); itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id));
final updatedPosts = await processNewItems( final updatedPosts =
itemsResult.value, client.profile.userId, client); await processNewItems(itemsResult.value, client.profile.userId, client);
_logger.finest(() { _logger.finest(() {
final postCount = _entries.values final postCount = _entries.values.where((e) => e.parentId.isEmpty).length;
.where((e) => e.parentId.isEmpty)
.length;
final commentCount = _entries.length - postCount; final commentCount = _entries.length - postCount;
final orphanCount = _entries.values final orphanCount = _entries.values
.where( .where(
@ -200,9 +197,11 @@ class EntryManagerService extends ChangeNotifier {
return Result.ok(updatedPosts); return Result.ok(updatedPosts);
} }
Future<List<EntryTreeItem>> processNewItems(List<TimelineEntry> items, Future<List<EntryTreeItem>> processNewItems(
String currentId, List<TimelineEntry> items,
FriendicaClient? client,) async { String currentId,
FriendicaClient? client,
) async {
items.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id))); items.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id)));
final allSeenItems = [...items]; final allSeenItems = [...items];
for (final item in items) { for (final item in items) {
@ -229,9 +228,7 @@ class EntryManagerService extends ChangeNotifier {
await StatusesClient(getIt<AccountsService>().currentProfile) await StatusesClient(getIt<AccountsService>().currentProfile)
.getPostOrComment(o.id, fullContext: true) .getPostOrComment(o.id, fullContext: true)
.andThenSuccessAsync((items) async { .andThenSuccessAsync((items) async {
final parentPostId = items final parentPostId = items.firstWhere((e) => e.parentId.isEmpty).id;
.firstWhere((e) => e.parentId.isEmpty)
.id;
_parentPostIds[o.id] = parentPostId; _parentPostIds[o.id] = parentPostId;
allSeenItems.addAll(items); allSeenItems.addAll(items);
for (final item in items) { for (final item in items) {
@ -259,7 +256,7 @@ class EntryManagerService extends ChangeNotifier {
for (final item in seenItemsCopy) { for (final item in seenItemsCopy) {
if (item.parentId.isEmpty) { if (item.parentId.isEmpty) {
final postNode = final postNode =
_postNodes.putIfAbsent(item.id, () => _Node(item.id)); _postNodes.putIfAbsent(item.id, () => _Node(item.id));
postNodesToReturn.add(postNode); postNodesToReturn.add(postNode);
allSeenItems.remove(item); allSeenItems.remove(item);
} else { } else {
@ -301,9 +298,7 @@ class EntryManagerService extends ChangeNotifier {
.toList(); .toList();
_logger.finest( _logger.finest(
'Completed processing new items ${client == null 'Completed processing new items ${client == null ? 'sub level' : 'top level'}');
? 'sub level'
: 'top level'}');
return updatedPosts; return updatedPosts;
} }
@ -312,11 +307,10 @@ class EntryManagerService extends ChangeNotifier {
final client = StatusesClient(getIt<AccountsService>().currentProfile); final client = StatusesClient(getIt<AccountsService>().currentProfile);
final result = await client final result = await client
.getPostOrComment(id, fullContext: false) .getPostOrComment(id, fullContext: false)
.andThenAsync((rootItems) async => .andThenAsync((rootItems) async => await client
await client .getPostOrComment(id, fullContext: true)
.getPostOrComment(id, fullContext: true) .andThenSuccessAsync(
.andThenSuccessAsync( (contextItems) async => [...rootItems, ...contextItems]))
(contextItems) async => [...rootItems, ...contextItems]))
.andThenSuccessAsync((items) async { .andThenSuccessAsync((items) async {
await processNewItems(items, client.profile.username, null); await processNewItems(items, client.profile.username, null);
}); });
@ -325,7 +319,7 @@ class EntryManagerService extends ChangeNotifier {
_logger.finest('$id post updated'); _logger.finest('$id post updated');
return _nodeToTreeItem(_getPostRootNode(id)!, client.profile.userId); return _nodeToTreeItem(_getPostRootNode(id)!, client.profile.userId);
}).mapError( }).mapError(
(error) { (error) {
_logger.finest('$id error updating: $error'); _logger.finest('$id error updating: $error');
return ExecError( return ExecError(
type: ErrorType.localError, type: ErrorType.localError,
@ -339,7 +333,7 @@ class EntryManagerService extends ChangeNotifier {
_logger.finest('Resharing post: $id'); _logger.finest('Resharing post: $id');
final client = StatusesClient(getIt<AccountsService>().currentProfile); final client = StatusesClient(getIt<AccountsService>().currentProfile);
final result = final result =
await client.resharePost(id).andThenSuccessAsync((item) async { await client.resharePost(id).andThenSuccessAsync((item) async {
await processNewItems([item], client.profile.username, null); await processNewItems([item], client.profile.username, null);
}); });
@ -347,7 +341,7 @@ class EntryManagerService extends ChangeNotifier {
_logger.finest('$id post updated after reshare'); _logger.finest('$id post updated after reshare');
return _nodeToTreeItem(_postNodes[id]!, client.profile.userId); return _nodeToTreeItem(_postNodes[id]!, client.profile.userId);
}).mapError( }).mapError(
(error) { (error) {
_logger.finest('$id error updating: $error'); _logger.finest('$id error updating: $error');
return ExecError( return ExecError(
type: ErrorType.localError, type: ErrorType.localError,
@ -361,7 +355,7 @@ class EntryManagerService extends ChangeNotifier {
_logger.finest('Unresharing post: $id'); _logger.finest('Unresharing post: $id');
final client = StatusesClient(getIt<AccountsService>().currentProfile); final client = StatusesClient(getIt<AccountsService>().currentProfile);
final result = final result =
await client.unResharePost(id).andThenSuccessAsync((item) async { await client.unResharePost(id).andThenSuccessAsync((item) async {
await processNewItems([item], client.profile.username, null); await processNewItems([item], client.profile.username, null);
}); });
@ -375,10 +369,9 @@ class EntryManagerService extends ChangeNotifier {
return Result.ok(true); return Result.ok(true);
} }
FutureResult<EntryTreeItem, ExecError> toggleFavorited(String id, FutureResult<EntryTreeItem, ExecError> toggleFavorited(
bool newStatus) async { String id, bool newStatus) async {
final client = final client = InteractionsClient(getIt<AccountsService>().currentProfile);
InteractionsClient(getIt<AccountsService>().currentProfile);
final result = await client.changeFavoriteStatus(id, newStatus); final result = await client.changeFavoriteStatus(id, newStatus);
if (result.isFailure) { if (result.isFailure) {
return result.errorCast(); return result.errorCast();
@ -388,7 +381,7 @@ class EntryManagerService extends ChangeNotifier {
_entries[update.id] = update; _entries[update.id] = update;
final node = update.parentId.isEmpty final node = update.parentId.isEmpty
? _postNodes[update.id]! ? _postNodes[update.id]!
: _postNodes[update.parentId]!.getChildById(update.id)!; : _postNodes[_parentPostIds[update.parentId]]!;
notifyListeners(); notifyListeners();
return Result.ok(_nodeToTreeItem(node, client.profile.userId)); return Result.ok(_nodeToTreeItem(node, client.profile.userId));
@ -468,7 +461,7 @@ class _Node {
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is _Node && runtimeType == other.runtimeType && id == other.id; other is _Node && runtimeType == other.runtimeType && id == other.id;
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;

View file

@ -22,8 +22,13 @@ class NotificationsManager extends ChangeNotifier {
idMapper: (nn) => nn.map((n) => n.id).toList(), idMapper: (nn) => nn.map((n) => n.id).toList(),
onRequest: _clientGetNotificationsRequest, onRequest: _clientGetNotificationsRequest,
); );
var _firstLoad = true;
List<UserNotification> get notifications { List<UserNotification> get notifications {
if (_notifications.isEmpty && _firstLoad) {
updateNotifications();
_firstLoad = false;
}
final result = List<UserNotification>.from(_notifications.values); final result = List<UserNotification>.from(_notifications.values);
result.sort((n1, n2) { result.sort((n1, n2) {
if (n1.dismissed == n2.dismissed) { if (n1.dismissed == n2.dismissed) {
@ -46,7 +51,7 @@ class NotificationsManager extends ChangeNotifier {
} }
FutureResult<List<UserNotification>, ExecError> updateNotifications() async { FutureResult<List<UserNotification>, ExecError> updateNotifications() async {
const initialPull = 25; const initialPull = 100;
final nn = <UserNotification>[]; final nn = <UserNotification>[];
if (_pm.pages.isEmpty) { if (_pm.pages.isEmpty) {
final result = await _pm.initialize(initialPull); final result = await _pm.initialize(initialPull);

View file

@ -2,7 +2,7 @@ name: relatica
description: A mobile and desktop client for interacting with the Friendica social network description: A mobile and desktop client for interacting with the Friendica social network
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 0.1.0+1 version: 0.2.0+1
environment: environment:
sdk: '>=2.18.2 <3.0.0' sdk: '>=2.18.2 <3.0.0'

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB