diff --git a/lib/business_logic/search/search.dart b/lib/business_logic/search/search.dart new file mode 100644 index 0000000..b308ae0 --- /dev/null +++ b/lib/business_logic/search/search.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:loris/business_logic/account/account.dart'; +import 'package:loris/business_logic/timeline/timeline.dart'; +import 'package:loris/global.dart' as global; +import 'package:http/http.dart' as http; + +class SearchResult { + final String identitiy; + final List accountModels; + final List postModels; + final String query; + + SearchResult( + this.identitiy, + this.query, { + this.postModels = const [], + this.accountModels = const [], + }); +} + +Future> searchForEntry( + String q, String identityName) async { + final identity = global.settings!.identities[identityName]!; + final headers = { + ...identity.getAuthHeaders(), + ...global.defaultHeaders, + }; + + final uri = Uri( + scheme: "https", + host: identity.instanceUrl, + path: "/api/v2/search", + queryParameters: {"q": q}, + ); + + var response = await http.get(uri, headers: headers); + if (response.statusCode != 200) { + response = await http.get( + Uri( + scheme: uri.scheme, + host: uri.host, + path: "/api/v1/search", + queryParameters: uri.queryParameters, + ), + headers: headers); + } + if (response.statusCode != 200) { + return MapEntry(response.statusCode, null); + } + + final json = jsonDecode(response.body); + + List accounts = []; + for (var account in json["accounts"]) { + accounts.add(AccountModel.fromJson( + account, + identityName, + )); + } + + List posts = []; + for (var post in json["statuses"]) { + posts.add(PostModel.fromJson(post, identityName)); + } + + return MapEntry( + 200, + SearchResult( + identityName, + q, + postModels: posts, + accountModels: accounts, + ), + ); +} diff --git a/lib/dialogues/search.dart b/lib/dialogues/search.dart new file mode 100644 index 0000000..ca4c085 --- /dev/null +++ b/lib/dialogues/search.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:localization/localization.dart'; +import 'package:loris/business_logic/account/account.dart'; +import 'package:loris/business_logic/timeline/timeline.dart'; +import 'package:loris/global.dart' as global; +import 'package:loris/business_logic/search/search.dart' as logic; + +import '../partials/post.dart'; + +class SearchDialogue extends StatefulWidget { + const SearchDialogue({super.key}); + + @override + State createState() => _SearchDialogueState(); +} + +class _SearchDialogueState extends State { + String searchText = ""; + String searchTextControl = ""; + Map results = {}; + int searched = 0; + + void searchSingle(String id) async { + final result = await logic.searchForEntry(searchText, id); + if (result.key == 200 && mounted) { + setState(() { + results.addAll({id: result.value!}); + searched++; + }); + } + } + + void search() { + searched = 0; + searchTextControl = searchText; + for (var id in global.settings!.identities.keys.toList()) { + searchSingle(id); + } + } + + @override + Widget build(BuildContext context) { + List accountModels = []; + List postModels = []; + results.forEach( + (key, value) { + for (var m in value.accountModels) { + accountModels.add(m); + } + for (var m in value.postModels) { + postModels.add(m); + } + }, + ); + return SimpleDialog( + contentPadding: const EdgeInsets.all(0), + titlePadding: const EdgeInsets.all(0), + title: Column( + children: [ + TextField( + style: Theme.of(context).textTheme.bodyMedium, + autofocus: true, + keyboardType: TextInputType.url, + onEditingComplete: () { + if (searchText.isNotEmpty) { + search(); + } + }, + onChanged: ((value) => setState(() { + searchText = value; + })), + decoration: const InputDecoration( + prefixIcon: Icon( + Icons.search, + ))), + if (searched < global.settings!.identities.length && + searchTextControl.isNotEmpty) + Column( + children: [ + SelectableText( + "${"searched".i18n()} $searched ${searched == 1 ? "instance".i18n() : "instances".i18n()}"), + const LinearProgressIndicator(), + ], + ), + ], + ), + children: [ + Container( + width: global.getWidth(context), + constraints: BoxConstraints( + maxWidth: global.getConstraints(context).maxWidth, + maxHeight: MediaQuery.of(context).size.height * 2 / 3, + ), + child: ListView.builder( + itemCount: accountModels.length + postModels.length, + itemBuilder: (context, index) { + if (index < accountModels.length) { + final model = accountModels[index]; + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + SelectableText(model.identity), + DisplayName(account: model) + ], + ), + ); + } + final model = postModels[index - accountModels.length]; + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + SelectableText(model.identity), + Post( + model: model, + ) + ], + ), + ); + }), + ), + ], + ); + } +} diff --git a/lib/pages/timeline/timeline.dart b/lib/pages/timeline/timeline.dart index f8d07de..e54a075 100644 --- a/lib/pages/timeline/timeline.dart +++ b/lib/pages/timeline/timeline.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:localization/localization.dart'; +import 'package:loris/dialogues/search.dart'; import 'package:loris/partials/thread.dart'; import '../../business_logic/timeline/timeline.dart' as tl; import '../../global.dart' as global; @@ -115,6 +116,12 @@ class TimelineState extends State { }, icon: const Icon(Icons.refresh), ), + IconButton( + onPressed: () => showDialog( + context: context, + builder: (context) => const SearchDialogue(), + ), + icon: const Icon(Icons.search)), DropdownButtonHideUnderline( child: DropdownButton( alignment: Alignment.center, diff --git a/lib/partials/thread.dart b/lib/partials/thread.dart index f10ae5c..7c96eda 100644 --- a/lib/partials/thread.dart +++ b/lib/partials/thread.dart @@ -103,20 +103,25 @@ class _ThreadState extends State { Padding( padding: const EdgeInsets.all(4), child: Container( - padding: const EdgeInsets.all(24), + clipBehavior: Clip.none, + foregroundDecoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + width: 2, + color: Theme.of(context).colorScheme.secondary, + ), + ), width: (MediaQuery.of(context).size.width * global.settings!.postWidth) - 56, constraints: global.getConstraints(context), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border.all( - color: Theme.of(context).colorScheme.secondary, width: 2), - borderRadius: BorderRadius.circular(8), - ), child: Material( - child: Column( - children: c, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: c, + ), ), ), ),