First cut at auto-complete of profiles

merge-requests/67/merge
Hank Grabowski 2022-12-28 15:56:27 -05:00
rodzic 2424d9d0a9
commit e3fa2d4bbd
9 zmienionych plików z 220 dodań i 76 usunięć

Wyświetl plik

@ -0,0 +1,77 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../globals.dart';
import '../../models/connection.dart';
import '../../services/connections_manager.dart';
class MentionAutocompleteOptions extends StatelessWidget {
const MentionAutocompleteOptions({
Key? key,
required this.query,
required this.onMentionUserTap,
}) : super(key: key);
final String query;
final ValueSetter<Connection> onMentionUserTap;
@override
Widget build(BuildContext context) {
final users = getIt<ConnectionsManager>().getMyContacts().where((it) {
final normalizedHandle = it.handle.toLowerCase();
final normalizedName = it.name.toLowerCase();
final normalizedQuery = query.toLowerCase();
return normalizedHandle.contains(normalizedQuery) ||
normalizedName.contains(normalizedQuery);
});
if (users.isEmpty) return const SizedBox.shrink();
return Card(
margin: const EdgeInsets.all(8),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.hardEdge,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
color: const Color(0xFFF7F7F8),
child: ListTile(
dense: true,
horizontalTitleGap: 0,
title: Text("Users matching '$query'"),
),
),
LimitedBox(
maxHeight: MediaQuery.of(context).size.height * 0.3,
child: ListView.separated(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: users.length,
separatorBuilder: (_, __) => const Divider(height: 0),
itemBuilder: (context, i) {
final user = users.elementAt(i);
return ListTile(
dense: true,
leading: CachedNetworkImage(
imageUrl: user.avatarUrl.toString(),
width: 25,
height: 25,
),
title: Text(user.name),
subtitle: Text('@${user.handle}'),
onTap: () {
print('tapped');
onMentionUserTap(user);
});
},
),
),
],
),
);
}
}

Wyświetl plik

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.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:result_monad/result_monad.dart';
@ -73,40 +74,42 @@ 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) {
return MultiProvider( return Portal(
providers: [ child: MultiProvider(
ChangeNotifierProvider<AuthService>( providers: [
create: (_) => getIt<AuthService>(), ChangeNotifierProvider<AuthService>(
lazy: true, create: (_) => getIt<AuthService>(),
lazy: true,
),
ChangeNotifierProvider<ConnectionsManager>(
create: (_) => getIt<ConnectionsManager>(),
lazy: true,
),
ChangeNotifierProvider<EntryManagerService>(
create: (_) => getIt<EntryManagerService>(),
lazy: true,
),
ChangeNotifierProvider<GalleryService>(
create: (_) => getIt<GalleryService>(),
lazy: true,
),
ChangeNotifierProvider<TimelineManager>(
create: (_) => getIt<TimelineManager>(),
),
ChangeNotifierProvider<NotificationsManager>(
create: (_) => getIt<NotificationsManager>(),
),
],
child: MaterialApp.router(
theme: ThemeData(
primarySwatch: Colors.indigo,
),
debugShowCheckedModeBanner: false,
scrollBehavior: AppScrollingBehavior(),
routerDelegate: appRouter.routerDelegate,
routeInformationProvider: appRouter.routeInformationProvider,
routeInformationParser: appRouter.routeInformationParser,
), ),
ChangeNotifierProvider<ConnectionsManager>(
create: (_) => getIt<ConnectionsManager>(),
lazy: true,
),
ChangeNotifierProvider<EntryManagerService>(
create: (_) => getIt<EntryManagerService>(),
lazy: true,
),
ChangeNotifierProvider<GalleryService>(
create: (_) => getIt<GalleryService>(),
lazy: true,
),
ChangeNotifierProvider<TimelineManager>(
create: (_) => getIt<TimelineManager>(),
),
ChangeNotifierProvider<NotificationsManager>(
create: (_) => getIt<NotificationsManager>(),
),
],
child: MaterialApp.router(
theme: ThemeData(
primarySwatch: Colors.indigo,
),
debugShowCheckedModeBanner: false,
scrollBehavior: AppScrollingBehavior(),
routerDelegate: appRouter.routerDelegate,
routeInformationProvider: appRouter.routeInformationProvider,
routeInformationParser: appRouter.routeInformationParser,
), ),
); );
} }

Wyświetl plik

@ -3,6 +3,8 @@ class Connection {
final String name; final String name;
final String handle;
final String id; final String id;
final Uri profileUrl; final Uri profileUrl;
@ -11,26 +13,27 @@ class Connection {
final Uri avatarUrl; final Uri avatarUrl;
Connection( Connection({this.status = ConnectionStatus.unknown,
{this.status = ConnectionStatus.unknown, this.name = '',
this.name = '', this.handle = '',
this.id = '', this.id = '',
Uri? profileUrl, Uri? profileUrl,
this.network = '', this.network = '',
Uri? avatarUrl}) Uri? avatarUrl})
: profileUrl = profileUrl ?? Uri(), : profileUrl = profileUrl ?? Uri(),
avatarUrl = avatarUrl ?? Uri(); avatarUrl = avatarUrl ?? Uri();
Connection copy( Connection copy({ConnectionStatus? status,
{ConnectionStatus? status, String? name,
String? name, String? handle,
String? id, String? id,
Uri? profileUrl, Uri? profileUrl,
String? network, String? network,
Uri? avatarUrl}) => Uri? avatarUrl}) =>
Connection( Connection(
status: status ?? this.status, status: status ?? this.status,
name: name ?? this.name, name: name ?? this.name,
handle: handle ?? this.handle,
id: id ?? this.id, id: id ?? this.id,
profileUrl: profileUrl ?? this.profileUrl, profileUrl: profileUrl ?? this.profileUrl,
network: network ?? this.network, network: network ?? this.network,
@ -39,13 +42,14 @@ class Connection {
@override @override
String toString() { String toString() {
return 'Connection{status: $status, name: $name, id: $id, profileUrl: $profileUrl, network: $network, avatar: $avatarUrl}'; return 'Connection{status: $status, name: $name, id: $id, handle: $handle, profileUrl: $profileUrl, network: $network, avatar: $avatarUrl}';
} }
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is Connection && runtimeType == other.runtimeType && id == other.id; other is Connection && runtimeType == other.runtimeType &&
id == other.id;
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;

Wyświetl plik

@ -2,9 +2,11 @@ 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:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../controls/autocomplete/mention_autocomplete_options.dart';
import '../controls/entry_media_attachments/gallery_selector_control.dart'; import '../controls/entry_media_attachments/gallery_selector_control.dart';
import '../controls/entry_media_attachments/media_uploads_control.dart'; import '../controls/entry_media_attachments/media_uploads_control.dart';
import '../controls/padding.dart'; import '../controls/padding.dart';
@ -33,6 +35,7 @@ class _EditorScreenState extends State<EditorScreen> {
TimelineEntry? parentEntry; TimelineEntry? parentEntry;
final newMediaItems = NewEntryMediaItems(); final newMediaItems = NewEntryMediaItems();
final existingMediaItems = <ImageEntry>[]; final existingMediaItems = <ImageEntry>[];
final focusNode = FocusNode();
var isSubmitting = false; var isSubmitting = false;
@ -120,22 +123,7 @@ class _EditorScreenState extends State<EditorScreen> {
), ),
), ),
const VerticalPadding(), const VerticalPadding(),
TextFormField( buildContentField(context),
readOnly: isSubmitting,
enabled: !isSubmitting,
maxLines: 10,
controller: contentController,
decoration: InputDecoration(
labelText: '$statusType Content',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).backgroundColor,
),
borderRadius: BorderRadius.circular(5.0),
),
),
),
const VerticalPadding(), const VerticalPadding(),
GallerySelectorControl(entries: existingMediaItems), GallerySelectorControl(entries: existingMediaItems),
const VerticalPadding(), const VerticalPadding(),
@ -181,6 +169,45 @@ class _EditorScreenState extends State<EditorScreen> {
); );
} }
Widget buildContentField(BuildContext context) {
return MultiTriggerAutocomplete(
textEditingController: contentController,
focusNode: focusNode,
optionsAlignment: OptionsAlignment.bottomEnd,
autocompleteTriggers: [
AutocompleteTrigger(
trigger: '@',
optionsViewBuilder: (context, autocompleteQuery, controller) {
return MentionAutocompleteOptions(
query: autocompleteQuery.query,
onMentionUserTap: (user) {
final autocomplete = MultiTriggerAutocomplete.of(context);
return autocomplete.acceptAutocompleteOption(user.handle);
},
);
},
),
],
fieldViewBuilder: (context, controller, focusNode) => TextFormField(
focusNode: focusNode,
readOnly: isSubmitting,
enabled: !isSubmitting,
maxLines: 10,
controller: controller,
decoration: InputDecoration(
labelText: '$statusType Content',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).backgroundColor,
),
borderRadius: BorderRadius.circular(5.0),
),
),
),
);
}
Widget buildCommentPreview(BuildContext context, TimelineEntry entry) { Widget buildCommentPreview(BuildContext context, TimelineEntry entry) {
_logger.finest('Build preview'); _logger.finest('Build preview');
return Column( return Column(

Wyświetl plik

@ -57,17 +57,18 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [],
Text(
notMyProfile
? profile.name
: '${profile.name} (Your Account)',
style: Theme.of(context).textTheme.titleLarge,
),
const HorizontalPadding(),
Text('( ${profile.status.label()} )'),
],
), ),
Text(
notMyProfile
? '${profile.name} (${profile.handle})'
: '${profile.name} (Your Account)',
softWrap: true,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const VerticalPadding(),
Text('( ${profile.status.label()} )'),
const VerticalPadding(), const VerticalPadding(),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,

Wyświetl plik

@ -1,4 +1,6 @@
import '../../globals.dart';
import '../../models/connection.dart'; import '../../models/connection.dart';
import '../../services/auth_service.dart';
extension ConnectionMastodonExtensions on Connection { extension ConnectionMastodonExtensions on Connection {
static Connection fromJson(Map<String, dynamic> json) { static Connection fromJson(Map<String, dynamic> json) {
@ -7,10 +9,20 @@ extension ConnectionMastodonExtensions on Connection {
final profileUrl = Uri.parse(json['url'] ?? ''); final profileUrl = Uri.parse(json['url'] ?? '');
const network = 'Unknown'; const network = 'Unknown';
final avatar = Uri.tryParse(json['avatar_static'] ?? '') ?? Uri(); final avatar = Uri.tryParse(json['avatar_static'] ?? '') ?? Uri();
final String handleFromJson = json['acct'];
late final String handle;
if (handleFromJson.contains('@')) {
handle = handleFromJson;
} else {
final server = getIt<AuthService>().currentServer;
handle = '$handleFromJson@$server';
}
return Connection( return Connection(
name: name, name: name,
id: id, id: id,
handle: handle,
profileUrl: profileUrl, profileUrl: profileUrl,
network: network, network: network,
avatarUrl: avatar, avatarUrl: avatar,

Wyświetl plik

@ -13,6 +13,7 @@ class AuthService extends ChangeNotifier {
FriendicaClient? _friendicaClient; FriendicaClient? _friendicaClient;
String _currentId = ''; String _currentId = '';
String _currentHandle = ''; String _currentHandle = '';
String _currentServer = '';
bool _loggedIn = false; bool _loggedIn = false;
Result<FriendicaClient, ExecError> get currentClient { Result<FriendicaClient, ExecError> get currentClient {
@ -32,6 +33,8 @@ class AuthService extends ChangeNotifier {
String get currentHandle => _currentHandle; String get currentHandle => _currentHandle;
String get currentServer => _currentServer;
Future<bool> getStoredLoginState() async { Future<bool> getStoredLoginState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool('logged-in') ?? false; return prefs.getBool('logged-in') ?? false;
@ -50,6 +53,7 @@ class AuthService extends ChangeNotifier {
_friendicaClient = client; _friendicaClient = client;
_currentId = result.value.id; _currentId = result.value.id;
_currentHandle = client.credentials.handle; _currentHandle = client.credentials.handle;
_currentServer = client.credentials.serverName;
notifyListeners(); notifyListeners();
return Result.ok(client); return Result.ok(client);
} }
@ -61,6 +65,7 @@ class AuthService extends ChangeNotifier {
_friendicaClient = null; _friendicaClient = null;
_currentId = ''; _currentId = '';
_currentHandle = ''; _currentHandle = '';
_currentServer = '';
notifyListeners(); notifyListeners();
} }

Wyświetl plik

@ -195,6 +195,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.7" version: "2.0.7"
flutter_portal:
dependency: transitive
description:
name: flutter_portal
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.3"
flutter_secure_storage: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@ -408,6 +415,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.1" version: "0.4.1"
multi_trigger_autocomplete:
dependency: "direct main"
description:
name: multi_trigger_autocomplete
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.1"
nested: nested:
dependency: transitive dependency: transitive
description: description:

Wyświetl plik

@ -1,4 +1,4 @@
name: flutter_portal name: friendica_portal
description: Friendica Social Network Application description: Friendica Social Network Application
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
@ -35,6 +35,7 @@ dependencies:
path: ^1.8.2 path: ^1.8.2
image: ^3.2.2 image: ^3.2.2
flutter_file_dialog: ^2.3.2 flutter_file_dialog: ^2.3.2
multi_trigger_autocomplete: ^0.1.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: