Merge branch 'group_management' into 'main'

Group management

See merge request mysocialportal/relatica!33
This commit is contained in:
HankG 2023-04-21 14:24:45 +00:00
commit 09410ab1f3
36 changed files with 1208 additions and 269 deletions

View file

@ -6,11 +6,19 @@
* The "copy text" action now copies the plain text not HTML version. So things like hashtags * The "copy text" action now copies the plain text not HTML version. So things like hashtags
show up as "#hashtag" not the HTML code around it with the link reference etc. show up as "#hashtag" not the HTML code around it with the link reference etc.
* Clicking on the "Home" button if you already are on that screen scrolls to the top. * Clicking on the "Home" button if you already are on that screen scrolls to the top.
* Auto-fill suggestions render at the top of the edit text field not bottom. * Auto-complete suggestions for hashtags and accounts render at the top of the edit text field not bottom.
* Contacts screen shows user's handle and last post time (as far as your server knows about) for the user
* Fixes * Fixes
* Seemingly disappearing contacts on Contacts screen have been corrected.
* New Features * New Features
* Responsive design for allowing the image and video attachments to scale up for larger videos * Responsive design for allowing the image and video attachments to scale up for larger videos
but limit content width on very large screens. but limit timeline/list width on very large screens.
* Groups (Mastodon Lists) management including:
* Creating new groups
* Renaming groups
* Deleting groups
* Add users to groups
* Remove users from groups
## Version 0.4.0 (beta), 04 April 2023 ## Version 0.4.0 (beta), 04 April 2023

View file

@ -1,14 +1,36 @@
import 'package:color_blindness/color_blindness.dart';
import 'package:color_blindness/color_blindness_color_scheme.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AppTheme { const _seedColor = Colors.indigo;
static ThemeData light = ThemeData( final _lightScheme = ColorScheme.fromSeed(
colorSchemeSeed: Colors.indigo, seedColor: _seedColor,
useMaterial3: true, brightness: Brightness.light,
); );
static ThemeData dark = ThemeData( final _darkScheme = ColorScheme.fromSeed(
colorSchemeSeed: Colors.indigo, seedColor: _seedColor,
brightness: Brightness.dark,
);
ThemeData buildTheme({
required Brightness brightness,
ColorBlindnessType blindnessType = ColorBlindnessType.none,
}) {
final baseScheme =
brightness == Brightness.light ? _lightScheme : _darkScheme;
late final ColorScheme scheme;
if (!kReleaseMode && blindnessType != ColorBlindnessType.none) {
scheme = colorBlindnessColorScheme(baseScheme, blindnessType);
} else {
scheme = baseScheme;
}
return ThemeData(
colorScheme: scheme,
brightness: brightness,
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark,
); );
} }

View file

@ -21,7 +21,11 @@ class MediaKitAvControl extends StatefulWidget {
class _MediaKitAvControlState extends State<MediaKitAvControl> { class _MediaKitAvControlState extends State<MediaKitAvControl> {
static final _logger = Logger('$MediaKitAvControl'); static final _logger = Logger('$MediaKitAvControl');
final player = Player(); final Player player = Player(
configuration: const PlayerConfiguration(
logLevel: MPVLogLevel.warn,
),
);
VideoController? controller; VideoController? controller;
var needToOpen = true; var needToOpen = true;
@ -30,7 +34,7 @@ class _MediaKitAvControlState extends State<MediaKitAvControl> {
super.initState(); super.initState();
Future.microtask(() async { Future.microtask(() async {
_logger.info('initializing'); _logger.info('initializing');
controller = await VideoController.create(player.handle); controller = await VideoController.create(player);
_logger.info('initialized'); _logger.info('initialized');
if (context.mounted) { if (context.mounted) {
setState(() {}); setState(() {});
@ -78,8 +82,17 @@ class _MediaKitAvControlState extends State<MediaKitAvControl> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
print('Building MediaKit Control'); print('Building MediaKit Control');
if (controller == null) { if (controller == null) {
return const Center( return Container(
child: CircularProgressIndicator(), width: widget.width,
height: widget.height,
color: Colors.black12,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
CircularProgressIndicator(),
],
),
); );
} }

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relatica/models/exec_error.dart';
import 'package:result_monad/result_monad.dart'; import 'package:result_monad/result_monad.dart';
import '../../models/gallery_data.dart'; import '../../models/gallery_data.dart';
@ -55,10 +56,9 @@ class _MediaUploadsControlState extends State<MediaUploadsControl> {
.entryMediaItems.attachments .entryMediaItems.attachments
.addAll(newEntries)), .addAll(newEntries)),
onError: (error) { onError: (error) {
if (mounted) { buildSnackbar(context,
buildSnackbar(context, 'Error selecting attachments: $error');
'Error selecting attachments: $error'); logError(error, _logger);
}
}); });
}, },
icon: const Icon(Icons.camera_alt), icon: const Icon(Icons.camera_alt),
@ -111,7 +111,7 @@ class _MediaUploadsControlState extends State<MediaUploadsControl> {
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder( border: OutlineInputBorder(
borderSide: BorderSide( borderSide: BorderSide(
color: Theme.of(context).backgroundColor, color: Theme.of(context).colorScheme.background,
), ),
borderRadius: BorderRadius.circular(5.0), borderRadius: BorderRadius.circular(5.0),
), ),

View file

@ -3,8 +3,10 @@ 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:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../globals.dart'; import '../globals.dart';
import '../models/exec_error.dart';
import '../models/user_notification.dart'; import '../models/user_notification.dart';
import '../routes.dart'; import '../routes.dart';
import '../services/connections_manager.dart'; import '../services/connections_manager.dart';
@ -139,11 +141,11 @@ class NotificationControl extends StatelessWidget {
onPressed: manager == null onPressed: manager == null
? null ? null
: () async { : () async {
final result = await manager.markSeen(notification); await manager.markSeen(notification).withError((error) {
if (result.isFailure) { buildSnackbar(
buildSnackbar(context, context, 'Error marking notification: $error');
'Error marking notification: ${result.error}'); logError(error, _logger);
} });
}, },
icon: Icon(Icons.close_rounded)), icon: Icon(Icons.close_rounded)),
); );

View file

@ -73,6 +73,12 @@ class StandardAppDrawer extends StatelessWidget {
'Direct Messages', 'Direct Messages',
() => context.pushNamed(ScreenPaths.messages), () => context.pushNamed(ScreenPaths.messages),
), ),
const Divider(),
buildMenuButton(
context,
'Groups Management',
() => context.pushNamed(ScreenPaths.groupManagement),
),
buildMenuButton( buildMenuButton(
context, context,
'Settings', 'Settings',

View file

@ -167,6 +167,7 @@ class _StatusControlState extends State<FlattenedTreeEntryControl> {
width: items.length > 1 width: items.length > 1
? ResponsiveSizesCalculator(context).maxThumbnailWidth ? ResponsiveSizesCalculator(context).maxThumbnailWidth
: ResponsiveSizesCalculator(context).viewPortalWidth, : ResponsiveSizesCalculator(context).viewPortalWidth,
height: ResponsiveSizesCalculator(context).maxThumbnailHeight,
); );
}, },
separatorBuilder: (context, index) { separatorBuilder: (context, index) {

View file

@ -1,6 +1,7 @@
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:relatica/models/exec_error.dart';
import 'package:result_monad/result_monad.dart'; import 'package:result_monad/result_monad.dart';
import '../../globals.dart'; import '../../globals.dart';
@ -102,6 +103,7 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
}); });
}, onError: (error) { }, onError: (error) {
buildSnackbar(context, 'Error resharing post by ${widget.entry.author}'); buildSnackbar(context, 'Error resharing post by ${widget.entry.author}');
logError(error, _logger);
}); });
setState(() { setState(() {
isProcessing = false; isProcessing = false;
@ -128,6 +130,7 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
}, onError: (error) { }, onError: (error) {
buildSnackbar( buildSnackbar(
context, 'Error un-resharing post by ${widget.entry.author}'); context, 'Error un-resharing post by ${widget.entry.author}');
logError(error, _logger);
}); });
setState(() { setState(() {
isProcessing = false; isProcessing = false;

View file

@ -3,40 +3,18 @@ import 'package:result_monad/result_monad.dart';
import '../../models/connection.dart'; import '../../models/connection.dart';
import '../../models/exec_error.dart'; import '../../models/exec_error.dart';
class IConnectionsRepo { abstract class IConnectionsRepo {
void clear() { void clear();
throw UnimplementedError();
}
bool addConnection(Connection connection) { bool upsertConnection(Connection connection);
throw UnimplementedError();
}
bool addAllConnections(Iterable<Connection> newConnections) { Result<Connection, ExecError> getById(String id);
throw UnimplementedError();
}
bool updateConnection(Connection connection) { Result<Connection, ExecError> getByName(String name);
throw UnimplementedError();
}
Result<Connection, ExecError> getById(String id) { Result<Connection, ExecError> getByHandle(String handle);
throw UnimplementedError();
}
Result<Connection, ExecError> getByName(String name) { List<Connection> getMyContacts();
throw UnimplementedError();
}
Result<Connection, ExecError> getByHandle(String handle) { List<Connection> getKnownUsersByName(String name);
throw UnimplementedError();
}
List<Connection> getMyContacts() {
throw UnimplementedError();
}
List<Connection> getKnownUsersByName(String name) {
throw UnimplementedError();
}
} }

View file

@ -1,26 +1,25 @@
import 'package:result_monad/result_monad.dart'; import 'package:result_monad/result_monad.dart';
import '../../models/connection.dart';
import '../../models/exec_error.dart'; import '../../models/exec_error.dart';
import '../../models/group_data.dart'; import '../../models/group_data.dart';
class IGroupsRepo { abstract class IGroupsRepo {
void addAllGroups(List<GroupData> groups) { void addAllGroups(List<GroupData> groups);
throw UnimplementedError();
}
void clearMyGroups() { void addConnectionToGroup(GroupData group, Connection connection);
throw UnimplementedError();
}
List<GroupData> getMyGroups() { void clearMyGroups();
throw UnimplementedError();
}
Result<List<GroupData>, ExecError> getGroupsForUser(String id) { void upsertGroup(GroupData group);
throw UnimplementedError();
}
bool updateConnectionGroupData(String id, List<GroupData> currentGroups) { void deleteGroup(GroupData group);
throw UnimplementedError();
} List<GroupData> getMyGroups();
Result<List<Connection>, ExecError> getGroupMembers(GroupData group);
Result<List<GroupData>, ExecError> getGroupsForUser(String id);
bool updateConnectionGroupData(String id, List<GroupData> currentGroups);
} }

View file

@ -16,25 +16,6 @@ class MemoryConnectionsRepo implements IConnectionsRepo {
_myContacts.clear(); _myContacts.clear();
} }
@override
bool addAllConnections(Iterable<Connection> newConnections) {
bool result = true;
for (final connection in newConnections) {
result &= addConnection(connection);
}
return result;
}
@override
bool addConnection(Connection connection) {
if (_connectionsById.containsKey(connection.id)) {
return false;
}
return updateConnection(connection);
}
@override @override
List<Connection> getKnownUsersByName(String name) { List<Connection> getKnownUsersByName(String name) {
return _connectionsByName.values.where((it) { return _connectionsByName.values.where((it) {
@ -47,7 +28,7 @@ class MemoryConnectionsRepo implements IConnectionsRepo {
} }
@override @override
bool updateConnection(Connection connection) { bool upsertConnection(Connection connection) {
_connectionsById[connection.id] = connection; _connectionsById[connection.id] = connection;
_connectionsByName[connection.name] = connection; _connectionsByName[connection.name] = connection;
int index = _myContacts.indexWhere((c) => c.id == connection.id); int index = _myContacts.indexWhere((c) => c.id == connection.id);

View file

@ -1,11 +1,13 @@
import 'package:result_monad/result_monad.dart'; import 'package:result_monad/result_monad.dart';
import '../../models/connection.dart';
import '../../models/exec_error.dart'; import '../../models/exec_error.dart';
import '../../models/group_data.dart'; import '../../models/group_data.dart';
import '../interfaces/groups_repo.intf.dart'; import '../interfaces/groups_repo.intf.dart';
class MemoryGroupsRepo implements IGroupsRepo { class MemoryGroupsRepo implements IGroupsRepo {
final _groupsForConnection = <String, List<GroupData>>{}; final _groupsForConnection = <String, List<GroupData>>{};
final _connectionsForGroup = <String, Set<Connection>>{};
final _myGroups = <GroupData>{}; final _myGroups = <GroupData>{};
@override @override
@ -25,11 +27,28 @@ class MemoryGroupsRepo implements IGroupsRepo {
return _myGroups.toList(); return _myGroups.toList();
} }
@override
Result<List<Connection>, ExecError> getGroupMembers(GroupData group) {
if (_connectionsForGroup.containsKey(group.id)) {
return Result.ok(_connectionsForGroup[group.id]!.toList());
}
return buildErrorResult(
type: ErrorType.notFound,
message: 'Group ${group.id} not found',
);
}
@override @override
void clearMyGroups() { void clearMyGroups() {
_myGroups.clear(); _myGroups.clear();
} }
@override
void addConnectionToGroup(GroupData group, Connection connection) {
_connectionsForGroup.putIfAbsent(group.id, () => {}).add(connection);
}
@override @override
void addAllGroups(List<GroupData> groups) { void addAllGroups(List<GroupData> groups) {
_myGroups.addAll(groups); _myGroups.addAll(groups);
@ -40,4 +59,20 @@ class MemoryGroupsRepo implements IGroupsRepo {
_groupsForConnection[id] = currentGroups; _groupsForConnection[id] = currentGroups;
return true; return true;
} }
@override
void upsertGroup(GroupData group) {
_connectionsForGroup.putIfAbsent(group.id, () => {});
_myGroups.remove(group);
_myGroups.add(group);
}
@override
void deleteGroup(GroupData group) {
for (final conGroups in _groupsForConnection.values) {
conGroups.remove(group);
}
_connectionsForGroup.remove(group.id);
_myGroups.remove(group);
}
} }

View file

@ -21,23 +21,6 @@ class ObjectBoxConnectionsRepo implements IConnectionsRepo {
box.removeAll(); box.removeAll();
} }
@override
bool addAllConnections(Iterable<Connection> newConnections) {
var allNew = true;
for (final c in newConnections) {
allNew &= addConnection(c);
}
return allNew;
}
@override
bool addConnection(Connection connection) {
if (memCache.addConnection(connection)) {
box.putAsync(connection);
}
return true;
}
@override @override
Result<Connection, ExecError> getById(String id) { Result<Connection, ExecError> getById(String id) {
final result = memCache.getById(id); final result = memCache.getById(id);
@ -109,9 +92,12 @@ class ObjectBoxConnectionsRepo implements IConnectionsRepo {
} }
@override @override
bool updateConnection(Connection connection) { bool upsertConnection(Connection connection) {
memCache.updateConnection(connection); memCache.upsertConnection(connection);
box.put(connection); getById(connection.id).match(
onSuccess: (existing) => box.put(connection.copy(obId: existing.obId)),
onError: (_) => box.put(connection),
);
return true; return true;
} }
@ -121,7 +107,7 @@ class ObjectBoxConnectionsRepo implements IConnectionsRepo {
return buildErrorResult(type: ErrorType.notFound); return buildErrorResult(type: ErrorType.notFound);
} }
memCache.addConnection(connection); memCache.upsertConnection(connection);
return Result.ok(connection); return Result.ok(connection);
} }
} }

View file

@ -154,7 +154,6 @@ class GroupsClient extends FriendicaClient {
GroupsClient(super.credentials) : super(); GroupsClient(super.credentials) : super();
// TODO Convert Groups to using paging for real (if it is supported)
FutureResult<List<GroupData>, ExecError> getGroups() async { FutureResult<List<GroupData>, ExecError> getGroups() async {
_logger.finest(() => 'Getting group (Mastodon List) data'); _logger.finest(() => 'Getting group (Mastodon List) data');
final url = 'https://$serverName/api/v1/lists'; final url = 'https://$serverName/api/v1/lists';
@ -168,7 +167,66 @@ class GroupsClient extends FriendicaClient {
: ExecError(type: ErrorType.localError, message: error.toString())); : ExecError(type: ErrorType.localError, message: error.toString()));
} }
// TODO Convert groups for connection to using paging for real (if available) FutureResult<PagedResponse<List<Connection>>, ExecError> getGroupMembers(
GroupData groupData,
PagingData page,
) async {
_networkStatusService.startConnectionUpdateStatus();
_logger.finest(() =>
'Getting members for group (Mastodon List) of name ${groupData.name} with paging: $page');
final baseUrl = 'https://$serverName/api/v1/lists/${groupData.id}/accounts';
final url = Uri.parse('$baseUrl?${page.toQueryParameters()}');
final result = await _getApiPagedRequest(url);
_networkStatusService.finishConnectionUpdateStatus();
return result
.andThenSuccess((response) => response.map((jsonArray) =>
(jsonArray as List<dynamic>)
.map((json) => ConnectionMastodonExtensions.fromJson(json))
.toList()))
.execErrorCast();
}
FutureResult<GroupData, ExecError> createGroup(String title) async {
_logger.finest(() => 'Creating group (Mastodon List) of name $title');
final url = 'https://$serverName/api/v1/lists';
final body = {
'title': title,
};
final result = await postUrl(
Uri.parse(url),
body,
headers: _headers,
).andThenSuccessAsync(
(data) async => GroupDataMastodonExtensions.fromJson(jsonDecode(data)));
return result.execErrorCast();
}
FutureResult<GroupData, ExecError> renameGroup(
String id, String title) async {
_logger.finest(() => 'Reanming group (Mastodon List) to name $title');
final url = 'https://$serverName/api/v1/lists/$id';
final body = {
'title': title,
};
final result = await putUrl(
Uri.parse(url),
body,
headers: _headers,
).andThenSuccessAsync((data) async {
final json = jsonDecode(data);
return GroupDataMastodonExtensions.fromJson(json);
});
return result.execErrorCast();
}
FutureResult<bool, ExecError> deleteGroup(GroupData groupData) async {
_logger.finest(
() => 'Reanming group (Mastodon List) to name ${groupData.name}');
final url = 'https://$serverName/api/v1/lists/${groupData.id}';
final result = await deleteUrl(Uri.parse(url), {}, headers: _headers);
return result.mapValue((_) => true).execErrorCast();
}
FutureResult<List<GroupData>, ExecError> getMemberGroupsForConnection( FutureResult<List<GroupData>, ExecError> getMemberGroupsForConnection(
String connectionId) async { String connectionId) async {
_logger.finest(() => _logger.finest(() =>

View file

@ -1,5 +1,8 @@
import 'package:device_preview/device_preview.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:media_kit/media_kit.dart';
import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart'; import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -24,7 +27,9 @@ import 'utils/old_android_letsencrypte_cert.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
// await dotenv.load(fileName: '.env'); // await dotenv.load(fileName: '.env');
const enablePreview = false;
Logger.root.level = Level.FINER; Logger.root.level = Level.FINER;
Logger.root.onRecord.listen((event) { Logger.root.onRecord.listen((event) {
final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName; final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName;
@ -36,7 +41,10 @@ void main() async {
await fixLetsEncryptCertOnOldAndroid(); await fixLetsEncryptCertOnOldAndroid();
await dependencyInjectionInitialization(); await dependencyInjectionInitialization();
runApp(const App()); runApp(DevicePreview(
enabled: !kReleaseMode && enablePreview,
builder: (context) => const App(),
));
} }
class App extends StatelessWidget { class App extends StatelessWidget {
@ -45,6 +53,7 @@ class App extends StatelessWidget {
// This widget is the root of your application. // This widget is the root of your application.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settingsService = getIt<SettingsService>();
return AnimatedBuilder( return AnimatedBuilder(
builder: (context, child) { builder: (context, child) {
return Portal( return Portal(
@ -102,9 +111,18 @@ class App extends StatelessWidget {
) )
], ],
child: MaterialApp.router( child: MaterialApp.router(
theme: AppTheme.light, useInheritedMediaQuery: true,
darkTheme: AppTheme.dark, locale: DevicePreview.locale(context),
themeMode: getIt<SettingsService>().themeMode, builder: DevicePreview.appBuilder,
theme: buildTheme(
brightness: Brightness.light,
blindnessType: settingsService.colorBlindnessType,
),
darkTheme: buildTheme(
brightness: Brightness.dark,
blindnessType: settingsService.colorBlindnessType,
),
themeMode: settingsService.themeMode,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
scrollBehavior: AppScrollingBehavior(), scrollBehavior: AppScrollingBehavior(),
routerDelegate: appRouter.routerDelegate, routerDelegate: appRouter.routerDelegate,

View file

@ -1,4 +1,6 @@
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart'; import 'package:result_monad/result_monad.dart';
import 'package:stack_trace/stack_trace.dart';
Result<T, ExecError> buildErrorResult<T>({ Result<T, ExecError> buildErrorResult<T>({
required ErrorType type, required ErrorType type,
@ -14,22 +16,28 @@ Result<T, ExecError> buildErrorResult<T>({
class ExecError { class ExecError {
final ErrorType type; final ErrorType type;
final String message; final String message;
final Trace trace;
ExecError({required this.type, this.message = ''}); ExecError({required this.type, this.message = '', Trace? trace})
: trace = trace ?? Trace.current(1);
ExecError copy({ ExecError copy({
ErrorType? type, ErrorType? type,
String? message, String? message,
Trace? trace,
}) => }) =>
ExecError( ExecError(
type: type ?? this.type, type: type ?? this.type,
message: message ?? this.message, message: message ?? this.message,
trace: trace ?? this.trace,
); );
@override @override
String toString() { String toString() {
return 'ExecError{type: $type, message: $message}'; return 'ExecError{type: $type, message: $message}';
} }
String printStackTrace() => trace.terse.toString();
} }
enum ErrorType { enum ErrorType {
@ -50,3 +58,8 @@ extension ExecErrorExtension<T, E> on Result<T, E> {
FutureResult<T, ExecError> execErrorCastAsync() async => execErrorCast(); FutureResult<T, ExecError> execErrorCastAsync() async => execErrorCast();
} }
void logError(ExecError error, Logger logger,
{Level logLevel = Level.INFO, String message = ''}) {
logger.log(logLevel, '$message $error\n${error.trace}');
}

View file

@ -3,16 +3,6 @@ class GroupData {
final String id; final String id;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is GroupData &&
runtimeType == other.runtimeType &&
id == other.id &&
name == other.name;
@override
int get hashCode => id.hashCode ^ name.hashCode;
final String name; final String name;
GroupData(this.id, this.name); GroupData(this.id, this.name);
@ -21,4 +11,12 @@ class GroupData {
String toString() { String toString() {
return 'GroupData{id: $id, name: $name}'; return 'GroupData{id: $id, name: $name}';
} }
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is GroupData && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
} }

View file

@ -7,6 +7,10 @@ import 'screens/editor.dart';
import 'screens/follow_request_adjudication_screen.dart'; import 'screens/follow_request_adjudication_screen.dart';
import 'screens/gallery_browsers_screen.dart'; import 'screens/gallery_browsers_screen.dart';
import 'screens/gallery_screen.dart'; import 'screens/gallery_screen.dart';
import 'screens/group_add_users_screen.dart';
import 'screens/group_create_screen.dart';
import 'screens/group_editor_screen.dart';
import 'screens/group_management_screen.dart';
import 'screens/home.dart'; import 'screens/home.dart';
import 'screens/interactions_viewer_screen.dart'; import 'screens/interactions_viewer_screen.dart';
import 'screens/message_thread_screen.dart'; import 'screens/message_thread_screen.dart';
@ -34,6 +38,7 @@ class ScreenPaths {
static String notifications = '/notifications'; static String notifications = '/notifications';
static String signin = '/signin'; static String signin = '/signin';
static String manageProfiles = '/switchProfiles'; static String manageProfiles = '/switchProfiles';
static String groupManagement = '/group_management';
static String signup = '/signup'; static String signup = '/signup';
static String userProfile = '/user_profile'; static String userProfile = '/user_profile';
static String userPosts = '/user_posts'; static String userPosts = '/user_posts';
@ -120,6 +125,28 @@ final appRouter = GoRouter(
builder: (context, state) => builder: (context, state) =>
MessageThreadScreen(parentThreadId: state.queryParams['uri']!), MessageThreadScreen(parentThreadId: state.queryParams['uri']!),
), ),
GoRoute(
name: ScreenPaths.groupManagement,
path: ScreenPaths.groupManagement,
builder: (context, state) => const GroupManagementScreen(),
routes: [
GoRoute(
path: 'show/:id',
builder: (context, state) => GroupEditorScreen(
groupId: state.params['id']!,
),
),
GoRoute(
path: 'new',
builder: (context, state) => GroupCreateScreen(),
),
GoRoute(
path: 'add_users/:id',
builder: (context, state) =>
GroupAddUsersScreen(groupId: state.params['id']!),
),
],
),
GoRoute( GoRoute(
path: ScreenPaths.settings, path: ScreenPaths.settings,
name: ScreenPaths.settings, name: ScreenPaths.settings,

View file

@ -8,6 +8,7 @@ import '../controls/current_profile_button.dart';
import '../controls/linear_status_indicator.dart'; import '../controls/linear_status_indicator.dart';
import '../controls/responsive_max_width.dart'; import '../controls/responsive_max_width.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';
@ -47,7 +48,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
); );
late Widget body; late Widget body;
if (contacts.isEmpty) { if (contacts.isEmpty) {
body = SingleChildScrollView( body = const SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(), physics: AlwaysScrollableScrollPhysics(),
child: Center( child: Center(
child: Text('No contacts'), child: Text('No contacts'),
@ -63,7 +64,14 @@ class _ContactsScreenState extends State<ContactsScreen> {
context.pushNamed(ScreenPaths.userProfile, context.pushNamed(ScreenPaths.userProfile,
params: {'id': contact.id}); params: {'id': contact.id});
}, },
title: Text(contact.name), title: Text(
'${contact.name} (${contact.handle})',
softWrap: true,
),
subtitle: Text(
'Last Status: ${contact.lastStatus?.toIso8601String() ?? "Unknown"}',
softWrap: true,
),
trailing: Text(contact.status.label()), trailing: Text(contact.status.label()),
); );
}, },
@ -73,7 +81,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
} }
return Scaffold( return Scaffold(
drawer: StandardAppDrawer(skipPopDismiss: true), drawer: const StandardAppDrawer(skipPopDismiss: true),
body: SafeArea( body: SafeArea(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () async { onRefresh: () async {
@ -103,13 +111,20 @@ class _ContactsScreenState extends State<ContactsScreen> {
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder( border: OutlineInputBorder(
borderSide: BorderSide( borderSide: BorderSide(
color: Theme.of(context).backgroundColor, color: Theme.of(context).highlightColor,
), ),
borderRadius: BorderRadius.circular(5.0), borderRadius: BorderRadius.circular(5.0),
), ),
), ),
), ),
), ),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: StatusAndRefreshButton(
valueListenable: nss.connectionUpdateStatus,
refreshFunction: () async => manager.updateAllContacts(),
),
) )
], ],
), ),

View file

@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart'; import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../controls/autocomplete/hashtag_autocomplete_options.dart'; import '../controls/autocomplete/hashtag_autocomplete_options.dart';
@ -15,6 +16,7 @@ import '../controls/padding.dart';
import '../controls/standard_appbar.dart'; import '../controls/standard_appbar.dart';
import '../controls/timeline/status_header_control.dart'; import '../controls/timeline/status_header_control.dart';
import '../globals.dart'; import '../globals.dart';
import '../models/exec_error.dart';
import '../models/group_data.dart'; import '../models/group_data.dart';
import '../models/image_entry.dart'; import '../models/image_entry.dart';
import '../models/link_preview_data.dart'; import '../models/link_preview_data.dart';
@ -110,13 +112,8 @@ class _EditorScreenState extends State<EditorScreen> {
loaded = true; loaded = true;
}); });
}, onError: (error) { }, onError: (error) {
if (context.mounted) { buildSnackbar(context, 'Error getting post for editing: $error');
buildSnackbar( logError(error, _logger);
context,
'Error getting post for editing: $error',
);
}
_logger.severe('Error getting post for editing: $error');
}); });
} }
@ -152,20 +149,25 @@ class _EditorScreenState extends State<EditorScreen> {
isSubmitting = true; isSubmitting = true;
}); });
final result = await manager.createNewStatus( final result = await manager
.createNewStatus(
bodyText, bodyText,
spoilerText: spoilerController.text, spoilerText: spoilerController.text,
inReplyToId: widget.parentId, inReplyToId: widget.parentId,
newMediaItems: newMediaItems, newMediaItems: newMediaItems,
existingMediaItems: existingMediaItems, existingMediaItems: existingMediaItems,
visibility: visibility, visibility: visibility,
); )
.withError((error) {
buildSnackbar(context, 'Error posting: $error');
logError(error, _logger);
});
setState(() { setState(() {
isSubmitting = false; isSubmitting = false;
}); });
if (result.isFailure) { if (result.isFailure) {
buildSnackbar(context, 'Error posting: ${result.error}');
return; return;
} }
@ -184,7 +186,8 @@ class _EditorScreenState extends State<EditorScreen> {
isSubmitting = true; isSubmitting = true;
}); });
final result = await manager.editStatus( final result = await manager
.editStatus(
widget.id, widget.id,
bodyText, bodyText,
spoilerText: spoilerController.text, spoilerText: spoilerController.text,
@ -192,13 +195,17 @@ class _EditorScreenState extends State<EditorScreen> {
newMediaItems: newMediaItems, newMediaItems: newMediaItems,
existingMediaItems: existingMediaItems, existingMediaItems: existingMediaItems,
newMediaItemVisibility: visibility, newMediaItemVisibility: visibility,
); )
.withError((error) {
buildSnackbar(context, 'Error updating $statusType: $error');
logError(error, _logger);
});
setState(() { setState(() {
isSubmitting = false; isSubmitting = false;
}); });
if (result.isFailure) { if (result.isFailure) {
buildSnackbar(context, 'Error Updating $statusType: ${result.error}');
return; return;
} }
@ -468,6 +475,7 @@ class _EditorScreenState extends State<EditorScreen> {
if (mounted) { if (mounted) {
buildSnackbar( buildSnackbar(
context, 'Error building link preview: $error'); context, 'Error building link preview: $error');
logError(error, _logger);
} }
}); });
}, },

View file

@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../controls/linear_status_indicator.dart';
import '../controls/responsive_max_width.dart';
import '../controls/status_and_refresh_button.dart';
import '../globals.dart';
import '../models/connection.dart';
import '../models/exec_error.dart';
import '../models/group_data.dart';
import '../routes.dart';
import '../services/auth_service.dart';
import '../services/connections_manager.dart';
import '../services/network_status_service.dart';
import '../utils/active_profile_selector.dart';
import '../utils/snackbar_builder.dart';
class GroupAddUsersScreen extends StatefulWidget {
final String groupId;
const GroupAddUsersScreen({super.key, required this.groupId});
@override
State<GroupAddUsersScreen> createState() => _GroupAddUsersScreenState();
}
class _GroupAddUsersScreenState extends State<GroupAddUsersScreen> {
static final _logger = Logger('$GroupAddUsersScreen');
var filterText = '';
late GroupData groupData;
@override
void initState() {
super.initState();
final manager =
getIt<ActiveProfileSelector<ConnectionsManager>>().activeEntry.value;
groupData =
manager.getMyGroups().where((g) => g.id == widget.groupId).first;
}
Future<void> addUserToGroup(
ConnectionsManager manager,
Connection connection,
) async {
final messageBase = '${connection.name} from ${groupData.name}';
final confirm = await showYesNoDialog(context, 'Add $messageBase?');
if (context.mounted && confirm == true) {
final message = await manager
.addUserToGroup(groupData, connection)
.withResult((p0) => setState(() {}))
.fold(
onSuccess: (_) => 'Added $messageBase',
onError: (error) => 'Error adding $messageBase: $error',
);
buildSnackbar(context, message);
}
}
@override
Widget build(BuildContext context) {
_logger.finer('Build');
final nss = getIt<NetworkStatusService>();
final activeProfile = context.watch<AccountsService>();
final manager = context
.watch<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry
.value;
final groupMembers = manager
.getGroupMembers(groupData)
.withError((e) => logError(e, _logger))
.getValueOrElse(() => [])
.toSet();
final allContacts = manager.getMyContacts();
final filterTextLC = filterText.toLowerCase();
final contacts = allContacts
.where((c) => !groupMembers.contains(c))
.where((c) =>
filterText.isEmpty ||
c.name.toLowerCase().contains(filterTextLC) ||
c.handle.toLowerCase().contains(filterTextLC))
.toList();
contacts.sort((c1, c2) => c1.name.compareTo(c2.name));
_logger.finer(
() =>
'# in group: ${groupMembers.length} # Contacts: ${allContacts.length}, #filtered: ${contacts.length}',
);
late Widget body;
if (contacts.isEmpty) {
body = const SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(),
child: Center(
child: Text('No contacts'),
));
} else {
body = ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (context, index) {
final contact = contacts[index];
return ListTile(
onTap: () {
context.pushNamed(ScreenPaths.userProfile,
params: {'id': contact.id});
},
title: Text(
'${contact.name} (${contact.handle})',
softWrap: true,
),
subtitle: Text(
'Last Status: ${contact.lastStatus?.toIso8601String() ?? "Unknown"}',
softWrap: true,
),
trailing: IconButton(
onPressed: () async => await addUserToGroup(manager, contact),
icon: const Icon(Icons.add)),
);
},
separatorBuilder: (context, index) => const Divider(),
itemCount: contacts.length);
}
return Scaffold(
appBar: AppBar(
title: const Text('Add Users'),
),
body: SafeArea(
child: RefreshIndicator(
onRefresh: () async {
if (nss.connectionUpdateStatus.value) {
return;
}
manager.refreshGroupMemberships(groupData);
return;
},
child: ResponsiveMaxWidth(
child: Column(
children: [
Text(
'Group: ${groupData.name}',
style: Theme.of(context).textTheme.bodyLarge,
softWrap: true,
),
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
onChanged: (value) {
setState(() {
filterText = value.toLowerCase();
});
},
decoration: InputDecoration(
labelText: 'Filter By Name',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).highlightColor,
),
borderRadius: BorderRadius.circular(5.0),
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: StatusAndRefreshButton(
valueListenable: nss.connectionUpdateStatus,
refreshFunction: () async =>
manager.refreshGroupMemberships(groupData),
),
)
],
),
StandardLinearProgressIndicator(nss.connectionUpdateStatus),
Expanded(child: body),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../controls/padding.dart';
import '../controls/standard_appbar.dart';
import '../services/connections_manager.dart';
import '../utils/active_profile_selector.dart';
import '../utils/snackbar_builder.dart';
class GroupCreateScreen extends StatefulWidget {
GroupCreateScreen({super.key});
@override
State<GroupCreateScreen> createState() => _GroupCreateScreenState();
}
class _GroupCreateScreenState extends State<GroupCreateScreen> {
final groupTextController = TextEditingController();
Future<void> createGroup(ConnectionsManager manager) async {
if (groupTextController.text.isEmpty) {
buildSnackbar(context, "Group name can't be empty");
return;
}
final result = await manager.createGroup(groupTextController.text);
if (context.mounted) {
result.match(
onSuccess: (_) => context.canPop() ? context.pop() : null,
onError: (error) {
buildSnackbar(context, 'Error trying to create new group: $error');
});
}
}
@override
Widget build(BuildContext context) {
final manager = context
.watch<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry
.value;
return Scaffold(
appBar: StandardAppBar.build(
context,
'New group',
withHome: false,
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
TextFormField(
controller: groupTextController,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: 'Group Name',
border: OutlineInputBorder(
borderSide: const BorderSide(),
borderRadius: BorderRadius.circular(5.0),
),
),
),
const VerticalPadding(),
ElevatedButton(
onPressed: () => createGroup(manager),
child: const Text('Create'),
),
],
),
),
);
}
}

View file

@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../controls/linear_status_indicator.dart';
import '../controls/padding.dart';
import '../controls/responsive_max_width.dart';
import '../controls/standard_appbar.dart';
import '../controls/status_and_refresh_button.dart';
import '../globals.dart';
import '../models/connection.dart';
import '../models/group_data.dart';
import '../routes.dart';
import '../services/connections_manager.dart';
import '../services/network_status_service.dart';
import '../utils/active_profile_selector.dart';
import '../utils/snackbar_builder.dart';
class GroupEditorScreen extends StatefulWidget {
final String groupId;
const GroupEditorScreen({super.key, required this.groupId});
@override
State<GroupEditorScreen> createState() => _GroupEditorScreenState();
}
class _GroupEditorScreenState extends State<GroupEditorScreen> {
final groupTextController = TextEditingController();
var processingUpdate = false;
var allowNameEditing = false;
var filterText = '';
late GroupData groupData;
Future<void> updateGroupName(
BuildContext context, ConnectionsManager manager) async {
processingUpdate = true;
final updated = groupTextController.text;
if (groupTextController.text != groupData.name) {
final confirm = await showYesNoDialog(
context, 'Change the group name from ${groupData.name} to $updated?');
if (context.mounted && confirm == true) {
await manager.renameGroup(widget.groupId, updated).match(
onSuccess: (updatedGroupData) {
groupData = updatedGroupData;
setState(() {
allowNameEditing = false;
});
}, onError: (error) {
buildSnackbar(context, 'Error renaming group: $error');
});
} else {
groupTextController.text = groupData.name;
}
}
processingUpdate = false;
}
Future<void> deleteGroup(ConnectionsManager manager) async {
final confirm = await showYesNoDialog(context,
"Permanently delete group ${groupData.name}? This can't be undone.");
if (context.mounted && confirm == true) {
await manager.deleteGroup(groupData).match(
onSuccess: (_) => context.canPop() ? context.pop() : null,
onError: (error) =>
buildSnackbar(context, 'Error trying to delete group: $error'),
);
}
}
Future<void> removeUserFromGroup(
ConnectionsManager manager,
Connection connection,
) async {
final messageBase = '${connection.name} from ${groupData.name}';
final confirm = await showYesNoDialog(context, 'Remove $messageBase?');
if (context.mounted && confirm == true) {
final message =
await manager.removeUserFromGroup(groupData, connection).fold(
onSuccess: (_) => 'Removed $messageBase',
onError: (error) => 'Error removing $messageBase: $error',
);
buildSnackbar(context, message);
}
}
@override
void initState() {
super.initState();
final manager =
getIt<ActiveProfileSelector<ConnectionsManager>>().activeEntry.value;
groupData = manager
.getMyGroups()
.where(
(g) => g.id == widget.groupId,
)
.first;
manager.refreshGroupMemberships(groupData);
groupTextController.text = groupData.name;
}
@override
Widget build(BuildContext context) {
final nss = getIt<NetworkStatusService>();
final manager = context
.watch<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry
.value;
final filterTextLC = filterText.toLowerCase();
final members = manager
.getGroupMembers(groupData)
.transform((ms) => ms
.where((m) =>
filterText.isEmpty ||
m.name.toLowerCase().contains(filterTextLC) ||
m.handle.toLowerCase().contains(filterTextLC))
.toList())
.getValueOrElse(() => <Connection>[]);
return Scaffold(
appBar: StandardAppBar.build(
context,
'Group Editor',
withHome: false,
actions: [
IconButton(
onPressed: () => deleteGroup(manager),
icon: const Icon(Icons.delete),
),
],
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: RefreshIndicator(
onRefresh: () async {
manager.refreshGroups();
},
child: ResponsiveMaxWidth(
child: Column(
children: [
StandardLinearProgressIndicator(nss.connectionUpdateStatus),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextFormField(
enabled: allowNameEditing,
readOnly: !allowNameEditing,
onEditingComplete: () async {
if (processingUpdate) {
return;
}
updateGroupName(context, manager);
},
onTapOutside: (_) async {
if (processingUpdate) {
return;
}
updateGroupName(context, manager);
},
controller: groupTextController,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: 'Group Name',
border: OutlineInputBorder(
borderSide: const BorderSide(),
borderRadius: BorderRadius.circular(5.0),
),
),
),
),
const HorizontalPadding(),
IconButton(
onPressed: () {
if (allowNameEditing) {
groupTextController.text = groupData.name;
}
setState(() {
allowNameEditing = !allowNameEditing;
});
},
icon: const Icon(Icons.edit),
),
],
),
),
const VerticalPadding(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Group Members:',
style: Theme.of(context).textTheme.headlineSmall),
IconButton(
onPressed: () {
context.push(
'${ScreenPaths.groupManagement}/add_users/${widget.groupId}');
},
icon: const Icon(Icons.add)),
],
),
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
onChanged: (value) {
setState(() {
filterText = value.toLowerCase();
});
},
decoration: InputDecoration(
labelText: 'Filter By Name',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).highlightColor,
),
borderRadius: BorderRadius.circular(5.0),
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: StatusAndRefreshButton(
valueListenable: nss.connectionUpdateStatus,
refreshFunction: () async =>
manager.refreshGroupMemberships(groupData),
),
)
],
),
Expanded(
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (context, index) {
final m = members[index];
return ListTile(
title: Text(
'${m.name} (${m.handle})',
softWrap: true,
),
subtitle: Text(
'Last Status: ${m.lastStatus?.toIso8601String() ?? "Unknown"}',
softWrap: true,
),
trailing: IconButton(
onPressed: () async =>
removeUserFromGroup(manager, m),
icon: const Icon(Icons.remove),
),
);
},
separatorBuilder: (_, __) => const Divider(),
itemCount: members.length,
),
),
],
),
),
),
));
}
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../controls/responsive_max_width.dart';
import '../controls/standard_appbar.dart';
import '../routes.dart';
import '../services/connections_manager.dart';
import '../utils/active_profile_selector.dart';
class GroupManagementScreen extends StatelessWidget {
const GroupManagementScreen({super.key});
@override
Widget build(BuildContext context) {
final manager = context
.watch<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry
.value;
final groups = manager.getMyGroups();
groups.sort((g1, g2) => g1.name.compareTo(g2.name));
return Scaffold(
appBar: StandardAppBar.build(
context,
'Groups Management',
withHome: false,
actions: [
IconButton(
onPressed: () => context.push(
'${ScreenPaths.groupManagement}/new',
),
icon: const Icon(Icons.add),
),
],
),
body: Center(
child: RefreshIndicator(
onRefresh: () async {
manager.refreshGroups();
},
child: ResponsiveMaxWidth(
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (context, index) {
final group = groups[index];
return ListTile(
title: Text(group.name),
onTap: () => context.push(
'${ScreenPaths.groupManagement}/show/${group.id}'),
);
},
separatorBuilder: (_, __) => const Divider(),
itemCount: groups.length,
),
),
),
));
}
}

View file

@ -27,7 +27,7 @@ class MessagesNewThread extends StatelessWidget {
Widget buildBody(BuildContext context) { Widget buildBody(BuildContext context) {
final border = OutlineInputBorder( final border = OutlineInputBorder(
borderSide: BorderSide( borderSide: BorderSide(
color: Theme.of(context).backgroundColor, color: Theme.of(context).colorScheme.background,
), ),
borderRadius: BorderRadius.circular(5.0), borderRadius: BorderRadius.circular(5.0),
); );

View file

@ -1,3 +1,5 @@
import 'package:color_blindness/color_blindness.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -20,6 +22,7 @@ class SettingsScreen extends StatelessWidget {
children: [ children: [
buildLowBandwidthWidget(settings), buildLowBandwidthWidget(settings),
buildThemeWidget(settings), buildThemeWidget(settings),
if (!kReleaseMode) buildColorBlindnessTestSettings(settings),
], ],
), ),
), ),
@ -55,4 +58,18 @@ class SettingsScreen extends StatelessWidget {
), ),
); );
} }
Widget buildColorBlindnessTestSettings(SettingsService settings) {
return ListTile(
title: const Text('Color Blindness Testing'),
trailing: DropdownButton<ColorBlindnessType>(
value: settings.colorBlindnessType,
items: ColorBlindnessType.values
.map((c) => DropdownMenuItem(value: c, child: Text(c.name)))
.toList(),
onChanged: (value) {
settings.colorBlindnessType = value ?? ColorBlindnessType.none;
}),
);
}
} }

View file

@ -46,16 +46,12 @@ extension DirectMessageFriendicaExtension on DirectMessage {
.andThenSuccess((cm) { .andThenSuccess((cm) {
if (getIt<AccountsService>().currentProfile.userId != senderId) { if (getIt<AccountsService>().currentProfile.userId != senderId) {
final s = ConnectionFriendicaExtensions.fromJson(json['sender']); final s = ConnectionFriendicaExtensions.fromJson(json['sender']);
if (cm.getById(s.id).isFailure) { cm.upsertConnection(s);
cm.addConnection(s);
}
} }
if (getIt<AccountsService>().currentProfile.userId != recipientId) { if (getIt<AccountsService>().currentProfile.userId != recipientId) {
final r = ConnectionFriendicaExtensions.fromJson(json['recipient']); final r = ConnectionFriendicaExtensions.fromJson(json['recipient']);
if (cm.getById(r.id).isFailure) { cm.upsertConnection(r);
cm.addConnection(r);
}
} }
}); });

View file

@ -28,7 +28,7 @@ extension NotificationMastodonExtension on UserNotification {
final from = ConnectionMastodonExtensions.fromJson(json['account']); final from = ConnectionMastodonExtensions.fromJson(json['account']);
getIt<ActiveProfileSelector<ConnectionsManager>>() getIt<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry .activeEntry
.andThenSuccess((manager) => manager.addConnection(from)); .andThenSuccess((manager) => manager.upsertConnection(from));
var statusId = ''; var statusId = '';
var statusLink = ''; var statusLink = '';
var content = ''; var content = '';

View file

@ -72,14 +72,14 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
return null; return null;
}); });
final connection = ConnectionMastodonExtensions.fromJson(json['account']); final connection = ConnectionMastodonExtensions.fromJson(json['account']);
connectionManager?.addConnection(connection); connectionManager?.upsertConnection(connection);
late final String reshareAuthor; late final String reshareAuthor;
late final String reshareAuthorId; late final String reshareAuthorId;
if (json['reblog'] != null) { if (json['reblog'] != null) {
final rebloggedUser = final rebloggedUser =
ConnectionMastodonExtensions.fromJson(json['reblog']['account']); ConnectionMastodonExtensions.fromJson(json['reblog']['account']);
connectionManager?.addConnection(rebloggedUser); connectionManager?.upsertConnection(rebloggedUser);
reshareAuthor = rebloggedUser.name; reshareAuthor = rebloggedUser.name;
reshareAuthorId = rebloggedUser.id; reshareAuthorId = rebloggedUser.id;
} else { } else {

View file

@ -22,20 +22,26 @@ class ConnectionsManager extends ChangeNotifier {
ConnectionsManager(this.conRepo, this.groupsRepo); ConnectionsManager(this.conRepo, this.groupsRepo);
bool addConnection(Connection connection) {
return conRepo.addConnection(connection);
}
List<Connection> getKnownUsersByName(String name) { List<Connection> getKnownUsersByName(String name) {
return conRepo.getKnownUsersByName(name); return conRepo.getKnownUsersByName(name);
} }
bool updateConnection(Connection connection) { bool upsertConnection(Connection connection) {
return conRepo.updateConnection(connection); if (connection.status != ConnectionStatus.unknown) {
return conRepo.upsertConnection(connection);
}
return conRepo.getById(connection.id).fold(
onSuccess: (original) => conRepo.upsertConnection(
connection.copy(status: original.status),
),
onError: (_) => conRepo.upsertConnection(connection),
);
} }
bool addAllConnections(Iterable<Connection> newConnections) { bool upsertAllConnections(Iterable<Connection> newConnections) {
return conRepo.addAllConnections(newConnections); newConnections.forEach(upsertConnection);
return true;
} }
Future<void> acceptFollowRequest(Connection connection) async { Future<void> acceptFollowRequest(Connection connection) async {
@ -47,7 +53,7 @@ class ConnectionsManager extends ChangeNotifier {
onSuccess: (update) { onSuccess: (update) {
_logger _logger
.finest('Successfully followed ${update.name}: ${update.status}'); .finest('Successfully followed ${update.name}: ${update.status}');
updateConnection(update); upsertConnection(update);
notifyListeners(); notifyListeners();
}, },
onError: (error) { onError: (error) {
@ -65,7 +71,7 @@ class ConnectionsManager extends ChangeNotifier {
onSuccess: (update) { onSuccess: (update) {
_logger _logger
.finest('Successfully followed ${update.name}: ${update.status}'); .finest('Successfully followed ${update.name}: ${update.status}');
updateConnection(update); upsertConnection(update);
notifyListeners(); notifyListeners();
}, },
onError: (error) { onError: (error) {
@ -83,7 +89,7 @@ class ConnectionsManager extends ChangeNotifier {
onSuccess: (update) { onSuccess: (update) {
_logger _logger
.finest('Successfully followed ${update.name}: ${update.status}'); .finest('Successfully followed ${update.name}: ${update.status}');
updateConnection(update); upsertConnection(update);
notifyListeners(); notifyListeners();
}, },
onError: (error) { onError: (error) {
@ -101,7 +107,7 @@ class ConnectionsManager extends ChangeNotifier {
onSuccess: (update) { onSuccess: (update) {
_logger _logger
.finest('Successfully followed ${update.name}: ${update.status}'); .finest('Successfully followed ${update.name}: ${update.status}');
updateConnection(update); upsertConnection(update);
notifyListeners(); notifyListeners();
}, },
onError: (error) { onError: (error) {
@ -119,7 +125,7 @@ class ConnectionsManager extends ChangeNotifier {
onSuccess: (update) { onSuccess: (update) {
_logger _logger
.finest('Successfully unfollowed ${update.name}: ${update.status}'); .finest('Successfully unfollowed ${update.name}: ${update.status}');
updateConnection(update); upsertConnection(update);
notifyListeners(); notifyListeners();
}, },
onError: (error) { onError: (error) {
@ -138,11 +144,13 @@ class ConnectionsManager extends ChangeNotifier {
final results = <String, Connection>{}; final results = <String, Connection>{};
var moreResults = true; var moreResults = true;
var maxId = -1; var maxId = -1;
const limit = 200; const limit = 50;
var currentPage = PagingData(limit: limit); var currentPage = PagingData(limit: limit);
final originalContacts = conRepo.getMyContacts().toSet();
while (moreResults) { while (moreResults) {
await client.getMyFollowers(currentPage).match(onSuccess: (followers) { await client.getMyFollowers(currentPage).match(onSuccess: (followers) {
for (final f in followers.data) { for (final f in followers.data) {
originalContacts.remove(f);
results[f.id] = f.copy(status: ConnectionStatus.theyFollowYou); results[f.id] = f.copy(status: ConnectionStatus.theyFollowYou);
int id = int.parse(f.id); int id = int.parse(f.id);
maxId = max(maxId, id); maxId = max(maxId, id);
@ -161,6 +169,7 @@ class ConnectionsManager extends ChangeNotifier {
while (moreResults) { while (moreResults) {
await client.getMyFollowing(currentPage).match(onSuccess: (following) { await client.getMyFollowing(currentPage).match(onSuccess: (following) {
for (final f in following.data) { for (final f in following.data) {
originalContacts.remove(f);
if (results.containsKey(f.id)) { if (results.containsKey(f.id)) {
results[f.id] = f.copy(status: ConnectionStatus.mutual); results[f.id] = f.copy(status: ConnectionStatus.mutual);
} else { } else {
@ -178,7 +187,11 @@ class ConnectionsManager extends ChangeNotifier {
}); });
} }
addAllConnections(results.values); for (final noLongerFollowed in originalContacts) {
results[noLongerFollowed.id] =
noLongerFollowed.copy(status: ConnectionStatus.none);
}
upsertAllConnections(results.values);
final myContacts = conRepo.getMyContacts().toList(); final myContacts = conRepo.getMyContacts().toList();
myContacts.sort((c1, c2) => c1.name.compareTo(c2.name)); myContacts.sort((c1, c2) => c1.name.compareTo(c2.name));
_logger.fine('# Contacts:${myContacts.length}'); _logger.fine('# Contacts:${myContacts.length}');
@ -194,6 +207,78 @@ class ConnectionsManager extends ChangeNotifier {
return myGroups; return myGroups;
} }
Result<List<Connection>, ExecError> getGroupMembers(GroupData group) {
return groupsRepo
.getGroupMembers(group)
.transform(
(members) => members
..sort((c1, c2) =>
c1.name.toLowerCase().compareTo(c2.name.toLowerCase())),
)
.execErrorCast();
}
FutureResult<GroupData, ExecError> createGroup(String newName) async {
final result = await GroupsClient(getIt<AccountsService>().currentProfile)
.createGroup(newName)
.withResultAsync((newGroup) async {
groupsRepo.upsertGroup(newGroup);
notifyListeners();
});
return result.execErrorCast();
}
FutureResult<GroupData, ExecError> renameGroup(
String id, String newName) async {
final result = await GroupsClient(getIt<AccountsService>().currentProfile)
.renameGroup(id, newName)
.withResultAsync((renamedGroup) async {
groupsRepo.upsertGroup(renamedGroup);
notifyListeners();
});
return result.execErrorCast();
}
FutureResult<bool, ExecError> deleteGroup(GroupData groupData) async {
final result = await GroupsClient(getIt<AccountsService>().currentProfile)
.deleteGroup(groupData)
.withResultAsync((_) async {
groupsRepo.deleteGroup(groupData);
notifyListeners();
});
return result.execErrorCast();
}
void refreshGroups() {
_updateMyGroups(true);
}
Future<void> refreshGroupMemberships(GroupData group) async {
var page = PagingData(limit: 50);
final client = GroupsClient(getIt<AccountsService>().currentProfile);
final allResults = <Connection>{};
var moreResults = true;
while (moreResults) {
await client.getGroupMembers(group, page).match(onSuccess: (results) {
moreResults = results.data.isNotEmpty && results.next != null;
page = results.next ?? page;
allResults.addAll(results.data);
}, onError: (error) {
_logger.severe('Error getting group listing data: $error');
moreResults = false;
});
}
groupsRepo.deleteGroup(group);
groupsRepo.upsertGroup(group);
for (final c in allResults) {
upsertConnection(c);
groupsRepo.addConnectionToGroup(group, c);
}
notifyListeners();
}
Result<List<GroupData>, ExecError> getGroupsForUser(String id) { Result<List<GroupData>, ExecError> getGroupsForUser(String id) {
final result = groupsRepo.getGroupsForUser(id); final result = groupsRepo.getGroupsForUser(id);
if (result.isSuccess) { if (result.isSuccess) {
@ -211,33 +296,31 @@ class ConnectionsManager extends ChangeNotifier {
FutureResult<bool, ExecError> addUserToGroup( FutureResult<bool, ExecError> addUserToGroup(
GroupData group, Connection connection) async { GroupData group, Connection connection) async {
_logger.finest('Adding ${connection.name} to group: ${group.name}'); _logger.finest('Adding ${connection.name} to group: ${group.name}');
final result = await GroupsClient(getIt<AccountsService>().currentProfile) return await GroupsClient(getIt<AccountsService>().currentProfile)
.addConnectionToGroup(group, connection); .addConnectionToGroup(group, connection)
result.match( .withResultAsync((_) async => refreshGroupMemberships(group))
onSuccess: (_) => _refreshGroupListData(connection.id, true), .withResult((_) => notifyListeners())
onError: (error) { .mapError((error) {
_logger _logger
.severe('Error adding ${connection.name} to group: ${group.name}'); .severe('Error adding ${connection.name} from group: ${group.name}');
}, return error;
); });
return result.execErrorCast();
} }
FutureResult<bool, ExecError> removeUserFromGroup( FutureResult<bool, ExecError> removeUserFromGroup(
GroupData group, Connection connection) async { GroupData group, Connection connection) async {
_logger.finest('Removing ${connection.name} from group: ${group.name}'); _logger.finest('Removing ${connection.name} from group: ${group.name}');
final result = await GroupsClient(getIt<AccountsService>().currentProfile) return GroupsClient(getIt<AccountsService>().currentProfile)
.removeConnectionFromGroup(group, connection); .removeConnectionFromGroup(group, connection)
result.match( .withResultAsync((_) async => refreshGroupMemberships(group))
onSuccess: (_) => _refreshGroupListData(connection.id, true), .withResult((_) => notifyListeners())
onError: (error) { .mapError(
(error) {
_logger.severe( _logger.severe(
'Error removing ${connection.name} from group: ${group.name}'); 'Error removing ${connection.name} from group: ${group.name}');
return error;
}, },
); );
return result.execErrorCast();
} }
Result<Connection, ExecError> getById(String id) { Result<Connection, ExecError> getById(String id) {
@ -298,7 +381,7 @@ class ConnectionsManager extends ChangeNotifier {
.getConnectionWithStatus(connection) .getConnectionWithStatus(connection)
.match( .match(
onSuccess: (update) { onSuccess: (update) {
updateConnection(update); upsertConnection(update);
if (withNotification) { if (withNotification) {
notifyListeners(); notifyListeners();
} }

View file

@ -1,3 +1,4 @@
import 'package:color_blindness/color_blindness.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -29,6 +30,16 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
ColorBlindnessType _colorBlindnessType = ColorBlindnessType.none;
ColorBlindnessType get colorBlindnessType => _colorBlindnessType;
set colorBlindnessType(ColorBlindnessType type) {
_colorBlindnessType = type;
_prefs.setString(_colorBlindnessTestingModeKey, type.name);
notifyListeners();
}
Future<void> initialize() async { Future<void> initialize() async {
if (_initialized) { if (_initialized) {
return; return;
@ -36,9 +47,22 @@ class SettingsService extends ChangeNotifier {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
_lowBandwidthMode = _prefs.getBool(_lowBandwidthModeKey) ?? false; _lowBandwidthMode = _prefs.getBool(_lowBandwidthModeKey) ?? false;
_themeMode = ThemeModeExtensions.parse(_prefs.getString(_themeModeKey)); _themeMode = ThemeModeExtensions.parse(_prefs.getString(_themeModeKey));
_colorBlindnessType = _colorBlindnessTypeFromPrefs(_prefs);
_initialized = true; _initialized = true;
} }
} }
const _lowBandwidthModeKey = 'LowBandwidthMode'; const _lowBandwidthModeKey = 'LowBandwidthMode';
const _themeModeKey = 'ThemeMode'; const _themeModeKey = 'ThemeMode';
const _colorBlindnessTestingModeKey = 'ColorBlindnessTestingMode';
ColorBlindnessType _colorBlindnessTypeFromPrefs(SharedPreferences prefs) {
final cbString = prefs.getString(_colorBlindnessTestingModeKey);
if (cbString?.isEmpty ?? true) {
return ColorBlindnessType.none;
}
return ColorBlindnessType.values.firstWhere(
(c) => c.name == cbString,
orElse: () => ColorBlindnessType.none,
);
}

View file

@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
Future<void> buildSnackbar(BuildContext context, String message, Future<void> buildSnackbar(BuildContext context, String message,
{int durationSec = 3}) async { {int durationSec = 3}) async {
if (!context.mounted) {
return;
}
final snackBar = SnackBar( final snackBar = SnackBar(
content: SelectableText(message), content: SelectableText(message),
duration: Duration(seconds: durationSec), duration: Duration(seconds: durationSec),

View file

@ -21,10 +21,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
sha256: d6347d54a2d8028e0437e3c099f66fdb8ae02c4720c1e7534c9f24c10351f85d sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.6" version: "3.3.7"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -185,6 +185,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.17.0"
color_blindness:
dependency: "direct main"
description:
name: color_blindness
sha256: "8e85c212aa21ed74e7067ed7ff0a3dce39a366023bd4ca17820981dc8681a6e6"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@ -249,14 +257,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.4.0" version: "0.4.0"
device_frame:
dependency: transitive
description:
name: device_frame
sha256: afe76182aec178d171953d9b4a50a43c57c7cf3c77d8b09a48bf30c8fa04dd9d
url: "https://pub.dev"
source: hosted
version: "1.1.0"
device_info_plus: device_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
name: device_info_plus name: device_info_plus
sha256: "1d6e5a61674ba3a68fb048a7c7b4ff4bebfed8d7379dbe8f2b718231be9a7c95" sha256: "435383ca05f212760b0a70426b5a90354fe6bd65992b3a5e27ab6ede74c02f5c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.0" version: "8.2.0"
device_info_plus_platform_interface: device_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -265,6 +281,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.0.0"
device_preview:
dependency: "direct main"
description:
name: device_preview
sha256: "2f097bf31b929e15e6756dbe0ec1bcb63952ab9ed51c25dc5a2c722d2b21fdaf"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -293,10 +317,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: "0d923fb610d0abf67f2149c3a50ef85f78bebecfc4d645719ca70bcf4abc788f" sha256: dcde5ad1a0cebcf3715ea3f24d0db1888bf77027a26c77d7779e8ef63b8ade62
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.7" version: "5.2.9"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -358,6 +382,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -431,18 +460,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_web_auth_2 name: flutter_web_auth_2
sha256: "6aebfb1797bb1dd38cd32753832670482792e4f0b9cef0329d357f889dbf07c9" sha256: "354002de1cf644b98af9b1b8c7a0f50f55d738667a54786ae4197e6ff87b224a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
flutter_web_auth_2_platform_interface: flutter_web_auth_2_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_web_auth_2_platform_interface name: flutter_web_auth_2_platform_interface
sha256: dd934033564cacff127b4776798dc2b27b2f2ebfd6b669746455b91c3611cfde sha256: "91ff7f0bf4ca530aabf857433db2fbcc3d1b8e3c5347ecf58e5ace8f9d29edb0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.3"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -456,6 +485,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.10.0" version: "0.10.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: aeac15850ef1b38ee368d4c53ba9a847e900bb2c53a4db3f6881cbb3cb684338
url: "https://pub.dev"
source: hosted
version: "2.2.0"
frontend_server_client: frontend_server_client:
dependency: transitive dependency: transitive
description: description:
@ -508,10 +545,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: feab99a20fd248c658c923ba98f4449ca6e575c3dee9fdf07146f4f33482c6bc sha256: "99c7fbd4f73da5268046374576af2f6fef970088038d626c5c689a359479af40"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.5" version: "6.5.7"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@ -564,18 +601,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker name: image_picker
sha256: cb25f04595a88450970dbe727243ba8cd21b6f7e0d7d1fc5b789fc6f52e95494 sha256: f202f5d730eb8219e35e80c4461fb3a779940ad30ce8fde1586df756e3af25e6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.7+1" version: "0.8.7+3"
image_picker_android: image_picker_android:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: dfb5b0f28b8786fcc662b7ed42bfb4b82a6cbbd74da1958384b10d40bdf212a7 sha256: "1ea6870350f56af8dab716459bd9d5dc76947e29e07a2ba1d0c172eaaf4f269c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.6+6" version: "0.8.6+7"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@ -588,10 +625,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_ios name: image_picker_ios
sha256: d4cb8ab04f770dab9d04c7959e5f6d22e8c5280343d425f9344f93832cf58445 sha256: a1546ff5861fc15812953d4733b520c3d371cec3d2859a001ff04c46c4d81883
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.7+2" version: "0.8.7+3"
image_picker_platform_interface: image_picker_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -600,6 +637,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.3" version: "2.6.3"
intl:
dependency: transitive
description:
name: intl
sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91"
url: "https://pub.dev"
source: hosted
version: "0.17.0"
io: io:
dependency: transitive dependency: transitive
description: description:
@ -668,58 +713,66 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit name: media_kit
sha256: defc249a792bf39346e6ee3ec40bbe48685c54aab86a25c4c27cc54a99afb6b1 sha256: "4c2b3bb600c063ad194934ea7439bce3135cc6b4f9555222c3dc12ca1aa1d85c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.4+1" version: "0.0.5+1"
media_kit_libs_android_video:
dependency: "direct main"
description:
name: media_kit_libs_android_video
sha256: "76fa95350b472f9dc163dd11e0145133c2e05456c06d0febb4e189922a68bd3c"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
media_kit_libs_ios_video: media_kit_libs_ios_video:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_libs_ios_video name: media_kit_libs_ios_video
sha256: a6ee06d466f539d161ef3b0c13f101713fd051d90c435a503420cb6a6f4c6e66 sha256: "28c6ddd5ed43263641293832c8d1fb3f24af81b4eba0b61d6da9bedadbf2e1b1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "1.0.4"
media_kit_libs_linux: media_kit_libs_linux:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_libs_linux name: media_kit_libs_linux
sha256: "7310b17dd2abc2e7363f78a273086445c2216c7b6dfb60933ca3814031d03814" sha256: "21acc71cbae3518b3aeef9023a6a3a3decb579a40153764333814987ccd61040"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.1" version: "1.0.2"
media_kit_libs_macos_video: media_kit_libs_macos_video:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_libs_macos_video name: media_kit_libs_macos_video
sha256: b3259875e201ec66d98ed33b32687ff14fcce7d91e74d420733c039556dff8cd sha256: ab1cbdf51400e30a9087bd7d6e10c6130d17296e76313fedd0ef0c57dae8c0f4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "1.0.4"
media_kit_libs_windows_video: media_kit_libs_windows_video:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_libs_windows_video name: media_kit_libs_windows_video
sha256: c2bcbe31e6e6f1e6ae5813c5fe9b7bfde1110c2e95643869919972a7845948e1 sha256: "99a3a85b185ae012a8e3bd596cf0ca425834477d0bec86207548d6ff7c926254"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.1" version: "1.0.2"
media_kit_native_event_loop: media_kit_native_event_loop:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_native_event_loop name: media_kit_native_event_loop
sha256: "677ea41d13a2013ca7fe050674eac3b5d891185d4300779727a5860a85cdda60" sha256: ed87140ad4b64156b2b470c8105f48d8cad7923c952ca05d23e02d28978d2cb3
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.2" version: "1.0.3"
media_kit_video: media_kit_video:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_video name: media_kit_video
sha256: "1a870a3d731a9ce34e12ec5baed05b3081164f0c85b71baf05900ca47ee5639c" sha256: "3860b1e8b2779a5702ecea7e6e3d8b16a79407ad478234008550cca660ac52ad"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.4" version: "0.0.6"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -728,14 +781,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.8.0" version: "1.8.0"
metadata_fetch:
dependency: "direct main"
description:
name: metadata_fetch
sha256: "2c79e69e71cbb051041da6a8a9dd3df0617db84482ab3f36b4952ff779b7580e"
url: "https://pub.dev"
source: hosted
version: "0.4.1"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@ -828,18 +873,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "019f18c9c10ae370b08dce1f3e3b73bc9f58e7f087bb5e921f06529438ac0ae7" sha256: da97262be945a72270513700a92b39dd2f4a54dad55d061687e2e37a6390366a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.24" version: "2.0.25"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "818b2dc38b0f178e0ea3f7cf3b28146faab11375985d815942a68eee11c2d0f7" sha256: ad4c4d011830462633f03eb34445a45345673dfd4faf1ab0b4735fbd93b19183
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.1" version: "2.2.2"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@ -900,10 +945,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: pointycastle name: pointycastle
sha256: c3120a968135aead39699267f4c74bc9a08e4e909e86bc1b0af5bfd78691123c sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.7.2" version: "3.7.3"
pool: pool:
dependency: transitive dependency: transitive
description: description:
@ -947,11 +992,12 @@ packages:
result_monad: result_monad:
dependency: "direct main" dependency: "direct main"
description: description:
name: result_monad path: "."
sha256: "8f7720b9d517dbb54d612e2f6c6c4f409d51374f0d9ff9749dfcb0e0c6ab2fd4" ref: HEAD
url: "https://pub.dev" resolved-ref: "12a2ae1e0830f4aff32f1d94835901de545cf917"
source: hosted url: "https://gitlab.com/HankG/dart-result-monad.git"
version: "2.0.2" source: git
version: "2.3.0"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -988,18 +1034,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "8304d8a1f7d21a429f91dee552792249362b68a331ac5c3c1caf370f658873f6" sha256: "7fa90471a6875d26ad78c7e4a675874b2043874586891128dc5899662c97db46"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.2"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_foundation name: shared_preferences_foundation
sha256: cf2a42fb20148502022861f71698db12d937c7459345a1bdaa88fc91a91b3603 sha256: "0c1c16c56c9708aa9c361541a6f0e5cc6fc12a3232d866a687a7b7db30032b07"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.1"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
@ -1089,12 +1135,12 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: sqlite3 name: sqlite3
sha256: "822d321a008e194d7929357e5b58d2e4a04ab670d137182f9759152aa33180ff" sha256: a3ba4b66a7ab170ce7aa3f5ac43c19ee8d6637afbe7b7c95c94112b4f4d91566
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.11.0"
stack_trace: stack_trace:
dependency: transitive dependency: "direct main"
description: description:
name: stack_trace name: stack_trace
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
@ -1201,18 +1247,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: dd729390aa936bf1bdf5cd1bc7468ff340263f80a2c4f569416507667de8e3c8 sha256: a52628068d282d01a07cd86e6ba99e497aa45ce8c91159015b2416907d78e411
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.26" version: "6.0.27"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: "3dedc66ca3c0bef9e6a93c0999aee102556a450afcc1b7bcfeace7a424927d92" sha256: "9af7ea73259886b92199f9e42c116072f05ff9bea2dcb339ab935dfc957392c2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.3" version: "6.1.4"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
@ -1225,10 +1271,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
sha256: "0ef2b4f97942a16523e51256b799e9aa1843da6c60c55eefbfa9dbc2dcb8331a" sha256: "91ee3e75ea9dadf38036200c5d3743518f4a5eb77a8d13fda1ee5764373f185e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.4" version: "3.0.5"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1289,10 +1335,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: video_player_avfoundation name: video_player_avfoundation
sha256: af308d08c672d5ff718c60127665249617c37a709cb8f0a18dd28a0360299b7c sha256: "75c6d68cd479a25f34d635149ba6887bc8f1b2b2921841121fd44ea0c5bc1927"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.3" version: "2.4.4"
video_player_platform_interface: video_player_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1321,18 +1367,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: web_socket_channel name: web_socket_channel
sha256: ca49c0bc209c687b887f30527fb6a9d80040b072cc2990f34b9bec3e7663101b sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.4.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: c9ebe7ee4ab0c2194e65d3a07d8c54c5d00bb001b76081c4a04cdb8448b59e46 sha256: a6f0236dbda0f63aa9a25ad1ff9a9d8a4eaaa5012da0dc59d21afdb1dc361ca4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.3" version: "3.1.4"
window_to_front: window_to_front:
dependency: transitive dependency: transitive
description: description:

View file

@ -26,19 +26,21 @@ dependencies:
image_picker: ^0.8.6 image_picker: ^0.8.6
logging: ^1.1.0 logging: ^1.1.0
markdown: ^7.0.1 markdown: ^7.0.1
media_kit: ^0.0.3 media_kit: ^0.0.5 # Primary package.
media_kit_libs_ios_video: ^1.0.2 media_kit_video: ^0.0.6 # For video rendering.
media_kit_libs_linux: ^1.0.1 media_kit_native_event_loop: ^1.0.3 # Support for higher number of concurrent instances & better performance.
media_kit_libs_macos_video: ^1.0.2 media_kit_libs_windows_video: ^1.0.2 # Windows package for video native libraries.
media_kit_libs_windows_video: ^1.0.1 media_kit_libs_android_video: ^1.0.0 # Android package for video native libraries.
media_kit_native_event_loop: ^1.0.2 media_kit_libs_macos_video: ^1.0.4 # macOS package for video native libraries.
media_kit_video: ^0.0.4 media_kit_libs_ios_video: ^1.0.4 # iOS package for video native libraries.
metadata_fetch: ^0.4.1 media_kit_libs_linux: ^1.0.2 # GNU/Linux dependency package. metadata_fetch: ^0.4.1
multi_trigger_autocomplete: ^0.1.1 multi_trigger_autocomplete: ^0.1.1
network_to_file_image: ^4.0.1 network_to_file_image: ^4.0.1
path: ^1.8.2 path: ^1.8.2
provider: ^6.0.4 provider: ^6.0.4
result_monad: ^2.0.2 result_monad:
git:
url: https://gitlab.com/HankG/dart-result-monad.git
scrollable_positioned_list: ^0.3.5 scrollable_positioned_list: ^0.3.5
shared_preferences: ^2.0.15 shared_preferences: ^2.0.15
sqlite3: ^1.9.1 sqlite3: ^1.9.1
@ -52,6 +54,9 @@ dependencies:
carousel_slider: ^4.2.1 carousel_slider: ^4.2.1
device_info_plus: ^8.0.0 device_info_plus: ^8.0.0
string_validator: ^0.3.0 string_validator: ^0.3.0
device_preview: ^1.1.0
color_blindness: ^0.1.2
stack_trace: ^1.11.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -8,6 +8,7 @@
#include <desktop_window/desktop_window_plugin.h> #include <desktop_window/desktop_window_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h> #include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h> #include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("DesktopWindowPlugin")); registry->GetRegistrarForPlugin("DesktopWindowPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar( MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
ObjectboxFlutterLibsPluginRegisterWithRegistrar( ObjectboxFlutterLibsPluginRegisterWithRegistrar(

View file

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
desktop_window desktop_window
flutter_secure_storage_windows flutter_secure_storage_windows
media_kit_libs_windows_video
media_kit_video media_kit_video
objectbox_flutter_libs objectbox_flutter_libs
url_launcher_windows url_launcher_windows
@ -12,7 +13,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
media_kit_libs_windows_video
media_kit_native_event_loop media_kit_native_event_loop
) )