diff --git a/lib/controls/autocomplete/mention_autocomplete_options.dart b/lib/controls/autocomplete/mention_autocomplete_options.dart new file mode 100644 index 0000000..59398c1 --- /dev/null +++ b/lib/controls/autocomplete/mention_autocomplete_options.dart @@ -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 onMentionUserTap; + + @override + Widget build(BuildContext context) { + final users = getIt().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); + }); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index d97a915..ead2fbc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,7 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:http/http.dart'; -import 'package:http_parser/http_parser.dart'; import 'package:logging/logging.dart'; +import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart'; import 'package:provider/provider.dart'; import 'package:result_monad/result_monad.dart'; @@ -26,9 +23,6 @@ void main() async { await dotenv.load(fileName: '.env'); Logger.root.level = Level.FINER; - MultipartFile.fromBytes('file', Uint8List.fromList([]), - filename: 'hello.jpg', contentType: MediaType('image', 'jpeg')); - // Logger.root.onRecord.listen((event) { // final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName; // final msg = @@ -81,40 +75,42 @@ class App extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { - return MultiProvider( - providers: [ - ChangeNotifierProvider( - create: (_) => getIt(), - lazy: true, + return Portal( + child: MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => getIt(), + lazy: true, + ), + ChangeNotifierProvider( + create: (_) => getIt(), + lazy: true, + ), + ChangeNotifierProvider( + create: (_) => getIt(), + lazy: true, + ), + ChangeNotifierProvider( + create: (_) => getIt(), + lazy: true, + ), + ChangeNotifierProvider( + create: (_) => getIt(), + ), + ChangeNotifierProvider( + create: (_) => getIt(), + ), + ], + child: MaterialApp.router( + theme: ThemeData( + primarySwatch: Colors.indigo, + ), + debugShowCheckedModeBanner: false, + scrollBehavior: AppScrollingBehavior(), + routerDelegate: appRouter.routerDelegate, + routeInformationProvider: appRouter.routeInformationProvider, + routeInformationParser: appRouter.routeInformationParser, ), - ChangeNotifierProvider( - create: (_) => getIt(), - lazy: true, - ), - ChangeNotifierProvider( - create: (_) => getIt(), - lazy: true, - ), - ChangeNotifierProvider( - create: (_) => getIt(), - lazy: true, - ), - ChangeNotifierProvider( - create: (_) => getIt(), - ), - ChangeNotifierProvider( - create: (_) => getIt(), - ), - ], - child: MaterialApp.router( - theme: ThemeData( - primarySwatch: Colors.indigo, - ), - debugShowCheckedModeBanner: false, - scrollBehavior: AppScrollingBehavior(), - routerDelegate: appRouter.routerDelegate, - routeInformationProvider: appRouter.routeInformationProvider, - routeInformationParser: appRouter.routeInformationParser, ), ); } diff --git a/lib/models/connection.dart b/lib/models/connection.dart index d259d27..43b13c2 100644 --- a/lib/models/connection.dart +++ b/lib/models/connection.dart @@ -3,6 +3,8 @@ class Connection { final String name; + final String handle; + final String id; final Uri profileUrl; @@ -11,34 +13,35 @@ class Connection { final Uri avatarUrl; - Connection( - {this.status = ConnectionStatus.unknown, - this.name = '', - this.id = '', - Uri? profileUrl, - this.network = '', - Uri? avatarUrl}) + Connection({this.status = ConnectionStatus.unknown, + this.name = '', + this.handle = '', + this.id = '', + Uri? profileUrl, + this.network = '', + Uri? avatarUrl}) : profileUrl = profileUrl ?? Uri(), avatarUrl = avatarUrl ?? Uri(); bool get isEmpty => name.isEmpty && - id.isEmpty && - network.isEmpty && - status == ConnectionStatus.unknown; + id.isEmpty && + network.isEmpty && + status == ConnectionStatus.unknown; bool get isNotEmpty => !isEmpty; - Connection copy( - {ConnectionStatus? status, - String? name, - String? id, - Uri? profileUrl, - String? network, - Uri? avatarUrl}) => + Connection copy({ConnectionStatus? status, + String? name, + String? handle, + String? id, + Uri? profileUrl, + String? network, + Uri? avatarUrl}) => Connection( status: status ?? this.status, name: name ?? this.name, + handle: handle ?? this.handle, id: id ?? this.id, profileUrl: profileUrl ?? this.profileUrl, network: network ?? this.network, @@ -47,13 +50,14 @@ class Connection { @override 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 bool operator ==(Object other) => identical(this, other) || - other is Connection && runtimeType == other.runtimeType && id == other.id; + other is Connection && runtimeType == other.runtimeType && + id == other.id; @override int get hashCode => id.hashCode; diff --git a/lib/models/credentials.dart b/lib/models/credentials.dart index 264724b..4668299 100644 --- a/lib/models/credentials.dart +++ b/lib/models/credentials.dart @@ -1,6 +1,7 @@ -import 'package:flutter_portal/models/exec_error.dart'; import 'package:result_monad/result_monad.dart'; +import 'exec_error.dart'; + class Credentials { final String username; final String password; diff --git a/lib/screens/editor.dart b/lib/screens/editor.dart index 9bc7edd..c710a74 100644 --- a/lib/screens/editor.dart +++ b/lib/screens/editor.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; +import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart'; import 'package:provider/provider.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/media_uploads_control.dart'; import '../controls/padding.dart'; @@ -33,6 +35,7 @@ class _EditorScreenState extends State { TimelineEntry? parentEntry; final newMediaItems = NewEntryMediaItems(); final existingMediaItems = []; + final focusNode = FocusNode(); var isSubmitting = false; @@ -120,22 +123,7 @@ class _EditorScreenState extends State { ), ), const VerticalPadding(), - TextFormField( - 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), - ), - ), - ), + buildContentField(context), const VerticalPadding(), GallerySelectorControl(entries: existingMediaItems), const VerticalPadding(), @@ -181,6 +169,45 @@ class _EditorScreenState extends State { ); } + 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) { _logger.finest('Build preview'); return Column( diff --git a/lib/screens/image_viewer_screen.dart b/lib/screens/image_viewer_screen.dart index d90e6c5..1a54b9b 100644 --- a/lib/screens/image_viewer_screen.dart +++ b/lib/screens/image_viewer_screen.dart @@ -4,13 +4,13 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart'; -import 'package:flutter_portal/utils/snackbar_builder.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../globals.dart'; import '../models/media_attachment.dart'; import '../services/auth_service.dart'; +import '../utils/snackbar_builder.dart'; class ImageViewerScreen extends StatelessWidget { final MediaAttachment attachment; diff --git a/lib/screens/user_profile_screen.dart b/lib/screens/user_profile_screen.dart index ba0bfc7..766671b 100644 --- a/lib/screens/user_profile_screen.dart +++ b/lib/screens/user_profile_screen.dart @@ -57,17 +57,18 @@ class _UserProfileScreenState extends State { Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - notMyProfile - ? profile.name - : '${profile.name} (Your Account)', - style: Theme.of(context).textTheme.titleLarge, - ), - const HorizontalPadding(), - Text('( ${profile.status.label()} )'), - ], + children: [], ), + 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(), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, diff --git a/lib/serializers/friendica/timeline_entry_friendica_extensions.dart b/lib/serializers/friendica/timeline_entry_friendica_extensions.dart index dd8ccd7..2a87666 100644 --- a/lib/serializers/friendica/timeline_entry_friendica_extensions.dart +++ b/lib/serializers/friendica/timeline_entry_friendica_extensions.dart @@ -1,10 +1,10 @@ -import 'package:flutter_portal/serializers/friendica/connection_friendica_extensions.dart'; -import 'package:flutter_portal/serializers/friendica/media_attachment_friendica_extensions.dart'; import 'package:logging/logging.dart'; import '../../models/location_data.dart'; import '../../models/timeline_entry.dart'; import '../../utils/dateutils.dart'; +import 'connection_friendica_extensions.dart'; +import 'media_attachment_friendica_extensions.dart'; final _logger = Logger('FriendicaTimelineEntrySerializer'); diff --git a/lib/serializers/mastodon/connection_mastodon_extensions.dart b/lib/serializers/mastodon/connection_mastodon_extensions.dart index 105adc0..95e537d 100644 --- a/lib/serializers/mastodon/connection_mastodon_extensions.dart +++ b/lib/serializers/mastodon/connection_mastodon_extensions.dart @@ -1,4 +1,6 @@ +import '../../globals.dart'; import '../../models/connection.dart'; +import '../../services/auth_service.dart'; extension ConnectionMastodonExtensions on Connection { static Connection fromJson(Map json) { @@ -7,10 +9,20 @@ extension ConnectionMastodonExtensions on Connection { final profileUrl = Uri.parse(json['url'] ?? ''); const network = 'Unknown'; 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().currentServer; + handle = '$handleFromJson@$server'; + } return Connection( name: name, id: id, + handle: handle, profileUrl: profileUrl, network: network, avatarUrl: avatar, diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 2e66ad4..d4e5921 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -13,6 +13,7 @@ class AuthService extends ChangeNotifier { FriendicaClient? _friendicaClient; String _currentId = ''; String _currentHandle = ''; + String _currentServer = ''; bool _loggedIn = false; Result get currentClient { @@ -32,6 +33,8 @@ class AuthService extends ChangeNotifier { String get currentHandle => _currentHandle; + String get currentServer => _currentServer; + Future getStoredLoginState() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool('logged-in') ?? false; @@ -50,6 +53,7 @@ class AuthService extends ChangeNotifier { _friendicaClient = client; _currentId = result.value.id; _currentHandle = client.credentials.handle; + _currentServer = client.credentials.serverName; notifyListeners(); return Result.ok(client); } @@ -61,6 +65,7 @@ class AuthService extends ChangeNotifier { _friendicaClient = null; _currentId = ''; _currentHandle = ''; + _currentServer = ''; notifyListeners(); } diff --git a/pubspec.lock b/pubspec.lock index 6fc9085..aa4353d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -195,6 +195,13 @@ packages: url: "https://pub.dartlang.org" source: hosted 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: dependency: "direct main" description: @@ -408,6 +415,13 @@ packages: url: "https://pub.dartlang.org" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ddd100a..41f6119 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,4 +1,4 @@ -name: flutter_portal +name: friendica_portal description: Friendica Social Network Application publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -35,6 +35,7 @@ dependencies: path: ^1.8.2 image: ^3.2.2 flutter_file_dialog: ^2.3.2 + multi_trigger_autocomplete: ^0.1.1 dev_dependencies: flutter_test: