Compare commits

...

94 Commits
0.2 ... main

Author SHA1 Message Date
zoe eff72ee74e add post deletion 2022-10-03 00:17:36 +02:00
zoe 6c1e8fca85 build for android 2022-10-02 09:55:02 +02:00
zoe 76e29ce7f5 make search button say search 2022-10-02 09:21:45 +02:00
zoe 25cf6e770e Update 'README.md' 2022-10-01 20:20:03 +00:00
zoe 0267214cba the halloween update 2022-10-01 19:15:07 +02:00
zoe 52151dab7c clean up search page design 2022-10-01 17:51:50 +02:00
zoe 2029c55e95 remove chat page 2022-10-01 17:34:18 +02:00
zoe f18518ad63 escape unwanted markdown characters in names 2022-10-01 17:32:49 +02:00
zoe 6fe0f6967e custom emojis in names and no more markdown 2022-10-01 17:19:22 +02:00
zoe d98150007e custom emojis on profile views 2022-10-01 15:58:03 +02:00
zoe b76e53def1 emoji in display names and post bodies 2022-10-01 15:51:02 +02:00
zoe 0caefbe4b9 make the compiler happy 2022-10-01 13:41:18 +02:00
zoe 95cecbed9c open profiles when clicking a link that starts with @ 2022-10-01 13:34:42 +02:00
zoe 756deea82c replace conversations page with conversations popup 2022-10-01 13:00:00 +02:00
zoe 7933caa158 remove conversations from main ribbon and instead place search there 2022-10-01 12:34:10 +02:00
zoe 8e7b0e013d add min lines to display name to make them look not weird 2022-10-01 11:59:43 +02:00
zoe e633750ced set color on settings page ribbon 2022-09-30 20:33:29 +02:00
zoe 0c5f512766 add ribbon to settings page 2022-09-30 20:31:08 +02:00
zoe 481dbfa623 follow requests 2022-09-30 20:06:24 +02:00
zoe ee226713ae add collapsing threads 2022-09-30 17:45:07 +02:00
zoe 51c3cf8b04 meow meow meow meow 2022-09-29 22:46:16 +02:00
zoe d7a35beb3a chat bubble time 2022-09-29 19:44:01 +02:00
zoe 2f32510bce fixed exception when uploading files with no mimetype 2022-09-29 15:55:58 +02:00
zoe d4a6186a35 fucked up things that nobody shall ever know 2022-09-28 00:43:59 +02:00
zoe d264fc4504 add sorting for convos 2022-09-28 00:00:12 +02:00
zoe a73614b5cc start dm feature 2022-09-27 21:49:11 +02:00
zoe aa46264c64 remove missing gotosocial profile headers 2022-09-26 20:14:35 +02:00
zoe 4d2a45ad6c fix bug where profile view would be too wide 2022-09-26 20:05:44 +02:00
zoe 429cca8ddb finish up for the day 2022-09-26 19:28:46 +02:00
zoe 2fe67e7964 add fancy and cool blue 2022-09-26 19:16:11 +02:00
zoe b281ffc061 unify spacing 2022-09-26 16:30:02 +02:00
zoe ad7aaa5c24 fix all the 3d effects by removing them 2022-09-26 14:08:39 +02:00
zoe 3101b19a49 fix boost and like buttons ruining the layout 2022-09-26 12:51:44 +02:00
zoe a60e144602 Merge pull request 'media-attachments' (#27) from media-attachments into main
Reviewed-on: #27
2022-09-26 10:35:06 +00:00
zoe 4a48a68de4 add comment 2022-09-26 12:34:26 +02:00
zoe 3743cd800f clean up theme even more 2022-09-26 12:31:32 +02:00
zoe 2caa91dad5 clean up make post dialogue 2022-09-26 12:11:00 +02:00
zoe 8eb4763ff3 clean up colors on search 2022-09-26 12:06:15 +02:00
zoe d077d9a293 clean up colors 2022-09-26 12:04:53 +02:00
zoe f324e43be3 clean up media attachment update 2022-09-26 11:50:00 +02:00
zoe 55fb99d9d9 went down to api v1 for media attachments 2022-09-25 20:27:20 +02:00
zoe d0235cac5d basic attachments are live 2022-09-25 20:07:47 +02:00
zoe 8aa49dc2ea media attachments 2022-09-25 18:21:26 +02:00
zoe 4ac218b065 file picker 2022-09-25 18:09:47 +02:00
zoe 5c4a28765c finish up for today 2022-09-24 00:30:49 +02:00
zoe 7d4b3160a8 fix size 2022-09-23 23:47:30 +02:00
zoe 1265136030 give internet permission 2022-09-23 21:53:37 +02:00
zoe 726f3bebee its android time 2022-09-23 19:08:05 +02:00
zoe 55c6c92678 add search 2022-09-23 15:03:28 +02:00
zoe 1992182f27 fix sizing 2022-09-11 22:42:11 +02:00
zoe 074f9c3c82 fix sizing 2022-09-11 22:29:41 +02:00
zoe e2caa421b8 finally fix visual bug 2022-09-08 12:12:54 +02:00
zoe 8bec897d52 finally fix visual bug 2022-09-08 11:51:40 +02:00
zoe 99e9294ece fix layout error 2022-09-08 10:35:45 +02:00
zoe 4b581400d5 finish threads! :) 2022-09-07 22:07:11 +02:00
zoe 631cabbf8d account switching for threads 2022-09-07 17:36:13 +02:00
zoe 0bbb74332d refactor threads 2022-09-07 15:31:03 +02:00
zoe 9d9d2d163c improve threads 2022-09-07 13:01:29 +02:00
zoe fc306dd2e6 update readme 2022-09-07 11:12:05 +02:00
zoe 75df7319d5 fix things not being material 2022-09-07 10:06:11 +02:00
zoe 5515fde880 threads view 2022-09-06 23:42:09 +02:00
zoe 4a797f0f41 fix missing headers 2022-09-06 16:02:51 +02:00
zoe a336e197b5 set version to 3 2022-09-06 12:08:27 +02:00
zoe 374c129adc mkxmkmx 2022-09-06 10:30:41 +02:00
zoe c65641a222 mkxmkmx 2022-09-06 00:53:53 +02:00
zoe 1b32158805 mkxmkmx 2022-09-06 00:23:15 +02:00
zoe ba9f4942bb mkxmkmx 2022-09-06 00:18:31 +02:00
zoe cb064cb806 fix posts on account adding many times 2022-09-05 23:56:06 +02:00
zoe 1f557d5734 Merge branch 'main' of git.kittycat.homes:zoe/loris 2022-09-05 23:32:02 +02:00
zoe 014b3987a1 eee 2022-09-05 23:31:53 +02:00
zoe 3ef67031c4 Merge branch 'main' of git.kittycat.homes:zoe/loris 2022-09-05 23:10:01 +02:00
zoe 25fb912c7a account display is done! 2022-09-05 23:09:49 +02:00
zoe d7383295a1 fix the button 2022-09-05 21:40:56 +02:00
zoe 0f01665ad0 account display is done! 2022-09-05 21:29:35 +02:00
zoe 2b0671d785 following n stuff 2022-09-05 19:01:04 +02:00
zoe effd78f758 Merge pull request 'tess theme :) v 2' (#26) from tess/loris:main into main
Reviewed-on: #26
2022-09-04 22:17:52 +00:00
tess c26680c2bc tess theme :) v 2 2022-09-05 00:07:51 +02:00
zoe 2c4eee1526 profile view 2022-09-04 22:17:48 +02:00
zoe 534e31e634 refactor profile view 2022-09-04 20:35:41 +02:00
zoe d976d9ec22 profile view 2022-09-04 20:03:24 +02:00
zoe 44bdefd99a fix time display 2022-09-04 16:20:42 +02:00
zoe 0e844936c7 fix colors on code blocks 2022-09-04 14:00:24 +02:00
zoe 96d7ab25df compiles on desktop again oops 2022-09-04 11:14:51 +02:00
zoe ea6b3c52eb compiles on desktop again oops 2022-09-04 10:12:52 +02:00
zoe 4f249fd34a websockets for web 2022-09-03 23:32:08 +02:00
zoe 3549d61045 remove headers when on web 2022-09-03 22:41:39 +02:00
zoe e7dd160a58 remove evil print commands 2022-09-03 21:47:18 +02:00
zoe 8b849fb6ad web client support! (still buggy) 2022-09-03 21:19:12 +02:00
zoe c029461303 web client support! (still buggy) 2022-09-03 21:08:53 +02:00
zoe 39accd5a6c update readme 2022-09-03 10:17:13 +02:00
zoe 8f0d26e299 update readme 2022-09-03 10:16:23 +02:00
zoe 7249a6eb52 update readme 2022-09-03 10:15:34 +02:00
zoe 04fea3393b update readme 2022-09-03 10:14:48 +02:00
zoe 3cd7ed542d fixed the thing where it does the thing its not supposed to do 2022-09-03 01:01:30 +02:00
55 changed files with 3591 additions and 622 deletions

1
.gitignore vendored
View File

@ -33,7 +33,6 @@ migrate_working_dir/
/build/
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols

View File

@ -1,3 +1,17 @@
# loris
the best fedi client for gotosocial and mastodon (soon)(i promise)
the best (soon)(i promise) fedi client for gotosocial and mastodon
## features
- [x] multi account support
- [x] viewing your timeline
- [x] notifications
- [x] making text posts and replies
- [x] liking and boosting posts
- [x] content warnings
- [x] viewing profiles
- [x] viewing posts with full context
- [x] search
- [x] posting media attachments
- [x] chat

1
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
-keep class androidx.lifecycle.DefaultLifecycleObserver

View File

@ -1,9 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="kittycat.homes.loris">
<application
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="loris"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -1,13 +1,316 @@
class AccountModel {
late String acct;
late String displayName;
late String avatar;
late String url;
import 'dart:convert';
AccountModel.fromJson(Map<String, dynamic> json) {
acct = json["acct"];
displayName = json["display_name"];
avatar = json["avatar"];
url = json["url"];
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:loris/business_logic/emoji/emoji.dart';
import 'package:loris/business_logic/timeline/timeline.dart';
import 'package:loris/global.dart' as global;
class AccountModel {
final String acct;
final String displayName;
final String avatar;
final String url;
final String id;
final String note;
final String header;
final bool locked;
final bool? discoverable;
final List<dynamic>? fields;
final bool? bot;
final bool? suspended;
final String identity;
final List<Emoji> emojis;
AccountModel({
required this.identity,
required this.note,
required this.header,
required this.locked,
this.discoverable,
this.fields,
this.bot,
this.suspended,
required this.acct,
required this.displayName,
required this.avatar,
required this.url,
required this.id,
required this.emojis,
});
factory AccountModel.fromJson(Map<String, dynamic> json, String identity) {
List<Emoji> emoji = [];
if (json["emojis"] != null) {
for (var e in json["emojis"]) {
emoji.add(Emoji.fromJson(e));
}
}
return AccountModel(
identity: identity,
id: json["id"],
acct: json["acct"],
displayName: json["display_name"],
avatar: json["avatar"],
url: json["url"],
note: json["note"],
discoverable: json["discoverable"],
header: json["header"],
locked: json["locked"],
bot: json["bot"],
fields: json["fields"],
suspended: json["suspended"],
emojis: emoji,
);
}
}
class RelationshipModel {
final String identity;
final bool blockedBy;
final bool blocking;
final bool endorsed;
final bool followedBy;
final bool following;
final String id;
final bool muting;
final bool mutingNotifications;
final String note;
final bool notifying;
final bool requested;
final bool showingReblogs;
RelationshipModel({
required this.identity,
required this.blockedBy,
required this.blocking,
required this.endorsed,
required this.followedBy,
required this.following,
required this.id,
required this.muting,
required this.mutingNotifications,
required this.note,
required this.notifying,
required this.requested,
required this.showingReblogs,
});
factory RelationshipModel.fromJson(
Map<String, dynamic> json,
String identity,
) {
return RelationshipModel(
identity: identity,
blockedBy: json["blocked_by"],
blocking: json["blocking"],
endorsed: json["endorsed"],
followedBy: json["followed_by"],
following: json["following"],
id: json["id"],
muting: json["muting"],
mutingNotifications: json["muting_notifications"],
note: json["note"],
notifying: json["notifying"],
requested: json["requested"],
showingReblogs: json["showing_reblogs"]);
}
}
Future<Map<int, RelationshipModel?>> getRelationship(
String identityName, String id) async {
final identity = global.settings!.identities[identityName]!;
Map<String, String> headers = identity.getAuthHeaders();
headers.addAll(global.defaultHeaders);
final uri = Uri(
scheme: "https",
host: identity.instanceUrl,
path: "/api/v1/accounts/relationships",
queryParameters: {"id": id},
);
final response = await http.get(uri, headers: headers);
if (response.statusCode == 200 && jsonDecode(response.body).isNotEmpty) {
return {
response.statusCode:
RelationshipModel.fromJson(jsonDecode(response.body)[0], identityName)
};
}
return {response.statusCode: null};
}
Future<Map<int, AccountModel?>> searchModel(
String identityName, String url) async {
final identity = global.settings!.identities[identityName]!;
Map<String, String> headers = identity.getAuthHeaders();
headers.addAll(global.defaultHeaders);
Map<String, String> params = {
"type": "accounts",
"q": url,
"limit": "1",
"resolve": "true",
};
final uri1 = Uri(
scheme: "https",
host: identity.instanceUrl,
path: "/api/v1/search",
queryParameters: params,
);
final uri2 = Uri(
scheme: "https",
host: identity.instanceUrl,
path: "/api/v2/search",
queryParameters: params,
);
final r1 = await http.get(uri1, headers: headers);
if (r1.statusCode == 200) {
List<dynamic> accounts = jsonDecode(r1.body)["accounts"];
if (accounts.isEmpty) {
return {r1.statusCode: null};
}
return {
r1.statusCode: AccountModel.fromJson(
jsonDecode(r1.body)["accounts"][0],
identityName,
)
};
}
final r2 = await http.get(uri2, headers: headers);
if (r2.statusCode == 200) {
List<dynamic> accounts = jsonDecode(r2.body)["accounts"];
if (accounts.isEmpty) {
return {r2.statusCode: null};
}
return {
r2.statusCode: AccountModel.fromJson(
jsonDecode(r2.body)["accounts"][0],
identityName,
)
};
}
return {r2.statusCode: null};
}
Future<MapEntry<int, List<ThreadModel>?>> getPostsForAccount(
AccountModel model,
String? maxid,
) async {
final identity = global.settings!.identities[model.identity];
var headers = identity!.getAuthHeaders();
headers.addAll(global.defaultHeaders);
final params = {
"limit": global.settings!.batchSize.toString(),
if (maxid != null) "max_id": maxid,
};
final uri = Uri(
scheme: "https",
host: identity.instanceUrl,
path: "/api/v1/accounts/${model.id}/statuses",
queryParameters: params,
);
final r = await http.get(uri, headers: headers);
if (r.statusCode != 200) {
return MapEntry(r.statusCode, null);
}
List<dynamic> rb = jsonDecode(r.body);
List<PostModel> posts = [];
for (var element in rb) {
posts.add(PostModel.fromJson(element, model.identity));
}
// posts.sort();
List<ThreadModel> threads = [];
for (var element in posts) {
threads.add(
await element.getThread(),
);
}
return MapEntry(r.statusCode, threads);
}
enum AccountInteractionTypes {
follow,
block,
mute,
}
extension AccountInteractionTypesExenstion on AccountInteractionTypes {
String get slug {
switch (this) {
case AccountInteractionTypes.block:
return "block";
case AccountInteractionTypes.follow:
return "follow";
case AccountInteractionTypes.mute:
return "mute";
}
}
IconData get icon {
switch (this) {
case AccountInteractionTypes.block:
return Icons.block;
case AccountInteractionTypes.follow:
return Icons.person_add;
case AccountInteractionTypes.mute:
return Icons.volume_off;
}
}
String get revokeSlug {
switch (this) {
case AccountInteractionTypes.block:
return "unblock";
case AccountInteractionTypes.follow:
return "unfollow";
case AccountInteractionTypes.mute:
return "unmute";
}
}
}
enum AccountTextInteractionTypes {
note,
report,
}
Future<MapEntry<int, RelationshipModel?>> performInteraction(
String identityName,
String accountId,
AccountInteractionTypes type,
bool revoke,
) async {
final identity = global.settings!.identities[identityName]!;
var headers = identity.getAuthHeaders();
headers.addAll(global.defaultHeaders);
headers.remove("Content-Type");
final uri = Uri(
scheme: "https",
host: identity.instanceUrl,
path:
"/api/v1/accounts/$accountId/${revoke ? type.revokeSlug : type.slug}");
final response = await http.post(uri, headers: headers);
if (response.statusCode != 200) {
return MapEntry(response.statusCode, null);
}
return MapEntry(
response.statusCode,
RelationshipModel.fromJson(
jsonDecode(response.body),
identityName,
),
);
}

View File

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:loris/business_logic/account/account.dart';
import 'package:url_launcher/url_launcher.dart';
@ -38,6 +39,10 @@ String _authCode = "";
String _url = "";
App? _app;
App? getApp() {
return _app;
}
Response readAuthcode(Request request) {
Map<String, String> params = request.url.queryParameters;
if (params.containsKey("code") && params["code"] != null) {
@ -72,20 +77,27 @@ Future<int> handleFullOauth(String url) async {
return response.statusCode;
}
_authCode = "";
var handler = const Pipeline().addHandler(readAuthcode);
var server = await shelf_io.serve(handler, 'localhost', 1312);
await pollCode();
server.close();
String token =
await getToken(_authCode, _app!.clientId, _app!.clientSecret, _url);
await saveIdentity(
token,
url,
_app!,
_authCode,
);
return 200;
if (!kIsWeb) {
// if this is not a web app we can simply start a server
// and listen for a code there
_authCode = "";
var handler = const Pipeline().addHandler(readAuthcode);
var server = await shelf_io.serve(handler, 'localhost', 1312);
await pollCode();
server.close();
String token =
await getToken(_authCode, _app!.clientId, _app!.clientSecret, _url);
await saveIdentity(
token,
url,
_app!.clientId,
_app!.clientSecret,
_authCode,
);
return 200;
} else {
return 6969696969696969;
}
} catch (e) {
return 400;
}
@ -94,7 +106,8 @@ Future<int> handleFullOauth(String url) async {
Future<bool> saveIdentity(
String token,
String baseurl,
App app,
String clientId,
String clientSecret,
String authCode,
) async {
Map<String, String> headers = {"Authorization": "Bearer $token"};
@ -108,10 +121,14 @@ Future<bool> saveIdentity(
headers: headers,
);
if (response.statusCode == 200) {
final account = AccountModel.fromJson(jsonDecode(response.body));
final account = AccountModel.fromJson(
jsonDecode(response.body),
"${jsonDecode(response.body)["acct"]}@$baseurl",
);
await global.settings!.addNewIdentity("${account.acct}@$baseurl");
await global.settings!.saveActiveIdentity("${account.acct}@$baseurl");
await global.settings!.identities["${account.acct}@$baseurl"]!.saveApp(app);
await global.settings!.identities["${account.acct}@$baseurl"]!
.saveApp(clientId, clientSecret);
await global.settings!.identities["${account.acct}@$baseurl"]!
.saveAuthCode(authCode);
await global.settings!.identities["${account.acct}@$baseurl"]!
@ -141,7 +158,7 @@ Future<http.Response> doOauthFlow() async {
String url = _url;
try {
http.Response response = await registerApp(url);
openBrowserForAuthCode(url, App.fromJson(jsonDecode(response.body)));
openBrowserForAuthCode(url, _app!);
return response;
} catch (e) {
return http.Response(jsonEncode({}), 404);
@ -151,19 +168,35 @@ Future<http.Response> doOauthFlow() async {
Future<http.Response> registerApp(String baseurl) async {
//String url = baseurl Uri."api/v1/apps";
Uri url = Uri.https(baseurl, "/api/v1/apps");
final Map<String, String> params = {
'client_name': global.name,
'redirect_uris': kIsWeb
? "${Uri.base.origin}/login/redirect.html"
: "http://localhost:1312",
'scopes': "read write follow push",
'website': global.website,
};
final response = await http.post(
url,
headers: global.defaultHeaders,
body: jsonEncode({
'client_name': global.name,
'redirect_uris': "http://localhost:1312",
'scopes': "read write follow push",
'website': global.website
}),
body: jsonEncode(params),
);
_app = App.fromJson(jsonDecode(response.body));
return response;
}
Uri getAuthUrl(String baseurl, App app) {
return Uri(
scheme: "https",
path: "$baseurl/oauth/authorize",
// ignore: prefer_interpolation_to_compose_strings
query: "client_id=" +
app.clientId +
"&scope=read+write+follow+push" +
"&redirect_uri=${kIsWeb ? "${Uri.base.origin}/login/redirect.html" : "http://localhost:1312"}" +
"&response_type=code");
}
void openBrowserForAuthCode(String baseurl, App app) {
Uri url = Uri(
scheme: "https",
@ -172,9 +205,14 @@ void openBrowserForAuthCode(String baseurl, App app) {
query: "client_id=" +
app.clientId +
"&scope=read+write+follow+push" +
"&redirect_uri=http://localhost:1312" +
"&redirect_uri=${kIsWeb ? "${Uri.base.origin}/login/redirect.html" : "http://localhost:1312"}" +
"&response_type=code");
launchUrl(url);
launchUrl(
mode: LaunchMode.externalApplication,
url,
webOnlyWindowName: "loris",
webViewConfiguration: const WebViewConfiguration(),
);
}
Future<String> getToken(
@ -184,21 +222,25 @@ Future<String> getToken(
String baseurl,
) async {
Uri url = Uri.https(baseurl, "/oauth/token");
final args = jsonEncode({
'grant_type': "authorization_code",
'client_id': appId,
'client_secret': clientSecret,
'redirect_uri': kIsWeb
? "${Uri.base.origin}/login/redirect.html"
: "http://localhost:1312",
'scope': "read write follow push",
'code': authCode,
});
final response = await http.post(
url,
headers: global.defaultHeaders,
body: jsonEncode({
'grant_type': "authorization_code",
'client_id': appId,
'client_secret': clientSecret,
'redirect_uri': "http://localhost:1312",
'scope': "read write follow push",
'code': authCode,
}),
body: args,
);
if (response.statusCode == 200) {
final dec = jsonDecode(response.body);
return dec["access_token"]!;
}
return "";
}

View File

@ -0,0 +1,143 @@
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 ConversationModel implements Comparable {
final String id;
final List<AccountModel> accounts;
final bool unread;
final PostModel? lastStatus;
final String identity;
ConversationModel(
{required this.id,
required this.accounts,
required this.unread,
this.lastStatus,
required this.identity});
factory ConversationModel.fromJson(
Map<String, dynamic> json, String identity) {
final List accountsJson = json["accounts"];
final List<AccountModel> accounts = accountsJson
.map(
(e) => AccountModel.fromJson(e, identity),
)
.toList();
return ConversationModel(
accounts: accounts,
id: json["id"],
identity: identity,
unread: json["unread"],
lastStatus: PostModel.fromJson(json["last_status"], identity));
}
@override
int compareTo(other) {
if (lastStatus == null && other.lastStatus == null) return 0;
if (lastStatus == null) return -1;
return lastStatus!.createdAt.compareTo(other.lastStatus!.createdAt);
}
String getAccountsString() {
String ret = "";
for (var acc in accounts) {
ret = "$ret${acc.acct}";
}
return ret;
}
}
class ConversationModelResult {
// list of models
final List<ConversationModel> models;
final Map<String, String?> maxIds;
ConversationModelResult(this.models, {this.maxIds = const {}});
}
/*
loads conversation models from timeline
*/
Future<ConversationModelResult> _getConversationModels(
String identityName,
String? maxId,
) async {
final settings = global.settings!;
final identity = global.settings!.identities[identityName]!;
final headers = {
...identity.getAuthHeaders(),
...global.defaultHeaders,
};
final Map<String, String> queries = {
"limit": settings.batchSize.toString(),
if (maxId != null) "max_id": maxId,
};
final uri = Uri(
scheme: "https",
host: identity.instanceUrl,
queryParameters: queries,
path: "/api/v1/conversations",
);
final result = await http.get(uri, headers: headers);
if (result.statusCode != 200) {
return ConversationModelResult([]);
}
String? newMaxId = result.headers["link"];
final firstOpen = newMaxId?.indexOf("<");
final firstClose = newMaxId?.indexOf(">");
newMaxId = newMaxId?.substring(firstOpen! + 1, firstClose);
if (newMaxId != null) {
final maxIdUri = Uri.parse(newMaxId);
newMaxId = maxIdUri.queryParameters["max_id"];
}
return ConversationModelResult(
jsonDecode(result.body)
.map<ConversationModel>(
(e) => ConversationModel.fromJson(e, identityName),
)
.toList(),
maxIds: {identityName: newMaxId},
);
}
Future<ConversationModelResult> getAllConversationModels(
Map<String, String?> maxIds) async {
List<Future> futureResults = [];
global.settings!.identities.forEach((key, value) {
futureResults.add(_getConversationModels(key, maxIds[key]));
});
List<ConversationModelResult> results = [];
List<ConversationModel> models = [];
for (var element in futureResults) {
final r = await element;
results.add(r);
models.addAll(r.models);
}
models.sort();
models = models.reversed.toList();
if (models.length > global.settings!.batchSize) {
models = models.sublist(0, global.settings!.batchSize);
}
Map<String, String?> newMaxIds = {};
for (var element in results) {
newMaxIds.addAll(element.maxIds);
}
Set<String> appearingIdentities = {};
for (var i in models) {
appearingIdentities.add(i.identity);
}
newMaxIds.removeWhere((key, value) => (appearingIdentities.contains(value)));
return ConversationModelResult(models, maxIds: newMaxIds);
}

View File

@ -0,0 +1,17 @@
class Emoji {
final String shortcode;
final String url;
Emoji({required this.shortcode, required this.url});
factory Emoji.fromJson(Map<String, dynamic> json) {
return Emoji(
shortcode: json["shortcode"],
url: json["url"],
);
}
}
String insertEmojiInMd(String input, Emoji emoji) {
return input.replaceAll(
":${emoji.shortcode}:", "![${emoji.shortcode}](${emoji.url})");
}

View File

@ -0,0 +1,57 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:loris/global.dart' as global;
import 'package:http/http.dart' as http;
class FileUpload {
String description;
final String path;
// media id for identity,
// gets set after first sucessfully uploading
Map<String, String> ids = {};
FileUpload({
required this.description,
required this.path,
this.ids = const {},
});
static Future<FileUpload> fromPath(String path, String description) async {
return FileUpload(
description: description,
path: path,
);
}
/*
sends a media attachments and returns the http status code
if the status code is 200, the String is the media attachments id,
otherwise it's the error response
*/
Future<MapEntry<int, String>> upload(String identityName) async {
final identity = global.settings!.identities[identityName]!;
final headers = {
...global.defaultHeaders,
...identity.getAuthHeaders(),
};
final uri =
Uri(scheme: "https", host: identity.instanceUrl, path: "/api/v1/media");
final request = MultipartRequest(
"POST",
uri,
);
request.headers.addAll(headers);
request.files.add(await MultipartFile.fromPath(
"file",
path,
));
request.fields.addAll({"description": description});
final response = await http.Response.fromStream(await request.send());
if (response.statusCode != 200 && response.statusCode != 202) {
return MapEntry(response.statusCode, jsonDecode(response.body)["error"]);
}
return MapEntry(response.statusCode, jsonDecode(response.body)["id"]);
}
}

View File

@ -0,0 +1,56 @@
import 'dart:convert';
import 'package:loris/business_logic/account/account.dart';
import 'package:loris/global.dart' as global;
import 'package:http/http.dart' as http;
Future<List<AccountModel>> _getFollowRequest(String identityName) async {
final identity = global.settings!.identities[identityName]!;
final headers = {...identity.getAuthHeaders(), ...global.defaultHeaders};
final Uri uri = Uri(
scheme: "https",
host: identity.instanceUrl,
path: "/api/v1/follow_requests",
);
final result = await http.get(uri, headers: headers);
if (result.statusCode != 200) return [];
List<AccountModel> models = [];
for (var m in jsonDecode(result.body)) {
models.add(AccountModel.fromJson(m, identityName));
}
return models;
}
Future<List<AccountModel>> getFollowRequests() async {
List<Future<List<AccountModel>>> pending = [];
global.settings!.identities.forEach((key, value) {
pending.add(_getFollowRequest(key));
});
List<AccountModel> finished = [];
for (var m in pending) {
finished.addAll(await m);
}
return finished;
}
// accept follow request or deny
// request is accepted unless deny is true
Future<RelationshipModel?> handleFollowRequest(AccountModel account,
{deny = false}) async {
final identity = global.settings!.identities[account.identity]!;
final headers = {...identity.getAuthHeaders(), ...global.defaultHeaders};
final Uri uri = Uri(
scheme: "https",
host: identity.instanceUrl,
path:
"/api/v1/follow_requests/${account.id}/${deny ? "reject" : "authorize"}",
);
final result = await http.post(uri, headers: headers);
if (result.statusCode != 200) {
return null;
}
return RelationshipModel.fromJson(jsonDecode(result.body), account.identity);
}

View File

@ -29,11 +29,13 @@ class InstanceConfiguration {
class StatusConfiguration {
final int maxChars;
final int maxMediaAttachments;
StatusConfiguration(this.maxChars);
StatusConfiguration(this.maxChars, this.maxMediaAttachments);
static StatusConfiguration fromJson(Map<String, dynamic> json) {
return StatusConfiguration(
json["max_characters"],
json["max_media_attachments"],
);
}
}

View File

@ -103,3 +103,21 @@ Future<int> makeFullInteraction(
}
return await makeInteractionFromUrl(id, posturl, type);
}
Future<int> deletePost(PostModel model) async {
final identity = global.settings!.identities[model.identity]!;
final headers = {
...identity.getAuthHeaders(),
...global.defaultHeaders,
};
final uri = Uri(
scheme: "https",
host: identity.instanceUrl,
path: "/api/v1/statuses/${model.id}",
);
final response = await http.delete(uri, headers: headers);
return response.statusCode;
}

View File

@ -18,7 +18,7 @@ class NotificationModel implements Comparable {
post = null;
time = json["created_at"];
id = json["id"];
account = AccountModel.fromJson(json["account"]);
account = AccountModel.fromJson(json["account"], identity);
type = NotificationType.values
.firstWhere((element) => element.param == json["type"]);
if (json["status"] != null) {

View File

@ -11,17 +11,19 @@ class MakePostModel {
final Visibility visibility;
final String? scheduledAt;
final String? inReplyToId;
List<String> mediaIds;
MakePostModel({
required this.identity,
required this.status,
required this.spoilerText,
required this.visibility,
required this.mediaIds,
this.scheduledAt,
this.inReplyToId,
});
Future<int> sendPost() async {
Future<http.Response> sendPost() async {
final headers = global.settings!.identities[identity]!.getAuthHeaders();
headers.addAll(global.defaultHeaders);
@ -29,6 +31,7 @@ class MakePostModel {
"status": status,
"sensitive": false,
"visibility": visibility.queryParam,
"media_ids": mediaIds,
};
if (inReplyToId != null) {
@ -55,6 +58,6 @@ class MakePostModel {
headers: headers,
body: jsonEncode(params),
);
return response.statusCode;
return response;
}
}

View File

@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:loris/business_logic/timeline/timeline.dart';
import 'package:loris/global.dart' as global;
import 'package:http/http.dart' as http;
class PostContext {
final List<PostModel> ancestors;
final List<PostModel> descendants;
PostContext({required this.ancestors, required this.descendants});
}
// first returns ancestors, then decendants
Future<MapEntry<int, PostContext?>> getContextForPost(PostModel model) async {
final identity = global.settings!.identities[model.identity]!;
var headers = identity.getAuthHeaders();
headers.addAll(global.defaultHeaders);
String id = model.reblogId ?? model.id;
final uri = Uri(
scheme: "https",
host: identity.instanceUrl,
path: "/api/v1/statuses/$id/context");
final r = await http.get(uri, headers: headers);
if (r.statusCode != 200) {
return MapEntry(r.statusCode, null);
}
final json = jsonDecode(r.body);
List<dynamic> ancestors = json["ancestors"]!;
List<dynamic> descendants = json["descendants"]!;
return MapEntry(
r.statusCode,
PostContext(
ancestors: ancestors
.map((e) => PostModel.fromJson(e, model.identity))
.toList(),
descendants: descendants
.map((e) => PostModel.fromJson(e, model.identity))
.toList(),
));
}

View File

@ -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<AccountModel> accountModels;
final List<PostModel> postModels;
final String query;
SearchResult(
this.identitiy,
this.query, {
this.postModels = const [],
this.accountModels = const [],
});
}
Future<MapEntry<int, SearchResult?>> 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<AccountModel> accounts = [];
for (var account in json["accounts"]) {
accounts.add(AccountModel.fromJson(
account,
identityName,
));
}
List<PostModel> posts = [];
for (var post in json["statuses"]) {
posts.add(PostModel.fromJson(post, identityName));
}
return MapEntry(
200,
SearchResult(
identityName,
q,
postModels: posts,
accountModels: accounts,
),
);
}

View File

@ -1,7 +1,6 @@
import 'package:flutter/painting.dart';
import 'package:loris/themes/themes.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../business_logic/auth/oauth.dart' as oauth;
import './websocket.dart' as websocket;
import 'package:loris/themes/themes.dart' as themes;
@ -72,11 +71,11 @@ class AccountSettings {
return await prefs.setString("$identity.$authCodeKey", code);
}
Future<void> saveApp(oauth.App app) async {
clientId = app.clientId;
clientSecret = app.clientSecret;
await prefs.setString("$identity.$clientSecretKey", app.clientSecret);
await prefs.setString("$identity.$clientIdKey", app.clientId);
Future<void> saveApp(String clientSecret, String clientId) async {
this.clientId = clientId;
this.clientSecret = clientSecret;
await prefs.setString("$identity.$clientSecretKey", clientSecret);
await prefs.setString("$identity.$clientIdKey", clientId);
}
Future<bool> saveToken(String token) async {
@ -128,7 +127,7 @@ class Settings {
settings.activeIdentity = settings.identities.keys.first;
}
settings.postWidth = settings.prefs.getDouble(postWidthKey) ?? 0.8;
settings.postWidth = settings.prefs.getDouble(postWidthKey) ?? 1.0;
settings.maxPostWidth = settings.prefs.getDouble(maxPostWidthKey) ?? 1000;
final themename = settings.prefs.getString(themeKey);

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:localization/localization.dart';
import 'package:loris/business_logic/account/account.dart';
import 'package:loris/business_logic/emoji/emoji.dart';
import 'package:loris/business_logic/posting/mentions.dart';
import 'package:loris/business_logic/timeline/media.dart';
import '../../global.dart' as global;
@ -83,10 +84,13 @@ class PostModel implements Comparable {
late bool reblogged;
late AccountModel account;
late AccountModel? rebloggedBy;
late PostModel? reblog;
late String? inReplyTo;
late List<MediaAttachmentModel> attachments;
late List<MentionModel> mentions = [];
// exists if post is a reblog
late String originalId;
late List<Emoji> emojis = [];
PostModel.fromJson(Map<String, dynamic> json, this.identity) {
id = json["id"] as String;
@ -94,11 +98,13 @@ class PostModel implements Comparable {
createdAt = json["created_at"];
// in case of reblog
if (json["reblog"] != null) {
rebloggedBy = AccountModel.fromJson(json["account"]);
rebloggedBy = AccountModel.fromJson(json["account"], identity);
json = json["reblog"];
reblogId = json["id"];
reblog = PostModel.fromJson(json, identity);
} else {
rebloggedBy = null;
reblog = null;
}
originalId = json["id"];
uri = json["uri"] as String;
@ -110,7 +116,8 @@ class PostModel implements Comparable {
spoilerText = json["spoiler_text"] as String;
favourited = json["favourited"] as bool;
reblogged = json["reblogged"] as bool;
account = AccountModel.fromJson(json["account"]);
inReplyTo = json["in_reply_to_id"];
account = AccountModel.fromJson(json["account"], identity);
attachments = [];
List<dynamic> jsonAttachmentList = json["media_attachments"];
for (int i = 0; i < jsonAttachmentList.length; i++) {
@ -120,6 +127,11 @@ class PostModel implements Comparable {
for (var element in jsonMentionList) {
mentions.add(MentionModel.fromJson(element));
}
List<dynamic>? jsonEmojiList = json["emojis"];
if (jsonEmojiList != null) {}
for (var element in jsonEmojiList!) {
emojis.add(Emoji.fromJson(element));
}
}
// get instance of account that received this post

View File

@ -1,10 +1,8 @@
import 'dart:async';
import 'package:loris/business_logic/settings.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../global.dart' as global;
IOWebSocketChannel? channel;
bool connected = false;
Map<String, Map<String, StreamController>> map = {};
@ -57,7 +55,7 @@ StreamController getStreamController(AccountSettings id, StreamType type) {
final uri =
Uri(scheme: scheme, host: host, path: path, queryParameters: query);
final controller = StreamController.broadcast();
final socket = IOWebSocketChannel.connect(uri);
final socket = WebSocketChannel.connect(uri);
controller.addStream(socket.stream);
return controller;
}

View File

@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/chat/chat.dart';
import 'package:loris/dialogues/full_post_view.dart';
import 'package:loris/dialogues/profile_view.dart';
import 'package:loris/partials/loadingbox.dart';
import 'package:loris/partials/post.dart';
import 'package:loris/partials/post_text_renderer.dart';
import 'package:loris/themes/themes.dart' as themes;
import 'package:loris/global.dart' as global;
class Chat extends StatefulWidget {
const Chat({super.key});
@override
State<Chat> createState() => _ChatState();
}
class _ChatState extends State<Chat> {
List<ConversationModel> conversations = [];
// map that stores max ids for each identity
Map<String, String?> maxIds = {};
final scrollController = ScrollController();
bool loading = false;
// loads more conversations and properly stores all ids
Future<void> updateConversations() async {
if (loading) return;
loading = true;
final models = await getAllConversationModels(maxIds);
if (!mounted) return;
setState(() {
conversations.addAll(models.models);
maxIds = models.maxIds;
});
loading = false;
}
@override
void initState() {
updateConversations();
scrollController.addListener(() {
if (scrollController.position.maxScrollExtent - scrollController.offset <
MediaQuery.of(context).size.height) {
updateConversations();
}
});
super.initState();
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SimpleDialog(
contentPadding: const EdgeInsets.all(0),
children: [
Container(
constraints: global.getConstraints(context),
width: global.getWidth(context),
height: MediaQuery.of(context).size.height * 2 / 3,
child: Column(
children: [
Expanded(
child: ListView.separated(
controller: scrollController,
shrinkWrap: true,
itemBuilder: ((context, index) {
if (index >= conversations.length) {
return const LoadingBox();
}
return ConversationButton(
model: conversations[index],
onTap: () {
showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) {
final con = conversations[index];
if (con.lastStatus != null) {
return FullPostView(
originPostModel: con.lastStatus!);
}
return ProfileView(model: con.accounts.first);
});
},
);
}),
separatorBuilder: (context, index) => const Divider(
height: themes.defaultSeperatorHeight,
color: Colors.transparent,
),
itemCount: conversations.length + 1),
),
Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2))),
),
],
),
),
],
);
}
}
class ConversationButton extends StatelessWidget {
const ConversationButton({
super.key,
required this.model,
required this.onTap,
});
final ConversationModel model;
final void Function() onTap;
@override
Widget build(BuildContext context) {
final List<Widget> people = [];
for (var p in model.accounts) {
people.add(DisplayName(account: p));
}
return Align(
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(themes.defaultRadius),
color: Theme.of(context).colorScheme.surface,
),
margin: const EdgeInsets.fromLTRB(themes.defaultSeperatorHeight * 2, 0,
themes.defaultSeperatorHeight * 2, 0),
width: global.getWidth(context),
constraints: global.getConstraints(context),
child: Material(
borderOnForeground: true,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(themes.defaultRadius),
side: BorderSide(
color: Theme.of(context).colorScheme.secondary, width: 2),
),
child: InkWell(
borderRadius: const BorderRadius.all(themes.defaultRadius),
onTap: onTap,
child: Padding(
padding: themes.defaultInsideMargins,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: people +
[
Divider(
color: Theme.of(context).hintColor,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.open_in_full),
label: Text("show".i18n())),
if (model.unread) const UnreadIndicator(),
],
),
Wrap(
alignment: WrapAlignment.spaceBetween,
children: [
SelectableText(model.getAccountsString()),
SelectableText(
"${"you-are".i18n()} ${model.identity}"),
],
),
if (model.lastStatus != null)
PostTextRenderer(
input: model.lastStatus!.content,
identityName: model.identity,
),
],
),
),
),
),
),
);
}
}
class UnreadIndicator extends StatefulWidget {
const UnreadIndicator({super.key});
@override
State<UnreadIndicator> createState() => _UnreadIndicatorState();
}
class _UnreadIndicatorState extends State<UnreadIndicator> {
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.mark_unread_chat_alt),
SelectableText("unread".i18n())
],
);
}
}

View File

@ -0,0 +1,146 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/account/account.dart';
import 'package:loris/business_logic/follow_request/followrequest.dart';
import 'package:loris/global.dart' as global;
import 'package:loris/partials/post.dart';
import 'package:loris/themes/themes.dart' as themes;
class FollowReqScreen extends StatefulWidget {
const FollowReqScreen({super.key});
@override
State<FollowReqScreen> createState() => _FollowReqScreenState();
}
class _FollowReqScreenState extends State<FollowReqScreen> {
List<AccountModel> requests = [];
bool loading = true;
Future<void> fetchRequests() async {
final results = await getFollowRequests();
if (mounted) {
setState(() {
requests = results;
loading = false;
});
}
}
@override
void initState() {
fetchRequests();
super.initState();
}
void handleFailure(RelationshipModel? model) {}
void updateList(AccountModel model) {
if (mounted) {
setState(() {
requests.remove(model);
});
}
}
@override
Widget build(BuildContext context) {
return BackdropFilter(
filter:
ImageFilter.blur(sigmaX: 10, sigmaY: 10, tileMode: TileMode.mirror),
child: SimpleDialog(
titlePadding: EdgeInsets.fromLTRB(0, themes.defaultRadius.x, 0, 0),
title: loading ? const LinearProgressIndicator() : null,
contentPadding: const EdgeInsets.all(0),
children: [
Container(
width: global.getWidth(context),
height: MediaQuery.of(context).size.height * 2 / 3,
constraints: global.getConstraints(context),
child: ListView.separated(
itemBuilder: (context, index) {
final account = requests[index];
return FollowRequestDisplay(
account: account,
accept: () async {
if (mounted) {
setState(() {
loading = true;
});
}
final result = await handleFollowRequest(account);
handleFailure(result);
if (result != null) {
updateList(account);
}
if (mounted) {
setState(() {
loading = false;
});
}
},
deny: (() async {
if (mounted) {
setState(() {
loading = true;
});
}
final result =
await handleFollowRequest(account, deny: true);
handleFailure(result);
if (result != null) {
updateList(account);
}
if (mounted) {
setState(() {
loading = false;
});
}
}),
);
},
separatorBuilder: (context, index) =>
Divider(color: Theme.of(context).hintColor),
itemCount: requests.length),
)
],
));
}
}
class FollowRequestDisplay extends StatelessWidget {
const FollowRequestDisplay(
{super.key,
required this.deny,
required this.accept,
required this.account});
final void Function()? deny;
final void Function()? accept;
final AccountModel account;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DisplayName(account: account),
SelectableText("${"requested-to-follow".i18n()} ${account.identity}"),
Wrap(alignment: WrapAlignment.spaceBetween, children: [
TextButton.icon(
onPressed: deny,
icon: const Icon(Icons.do_not_disturb_alt_outlined),
label: Text("deny".i18n())),
TextButton.icon(
onPressed: accept,
icon: const Icon(Icons.check),
label: Text("accept".i18n()))
]),
],
),
);
}
}

View File

@ -0,0 +1,284 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/network_tools/get_post_from_url.dart';
import 'package:loris/business_logic/posts/posts.dart';
import 'package:loris/business_logic/timeline/timeline.dart';
import 'package:loris/global.dart' as global;
import 'package:loris/themes/themes.dart' as themes;
import '../partials/post.dart';
class FullPostView extends StatefulWidget {
const FullPostView({
super.key,
required this.originPostModel,
this.identities,
this.hidden = true,
this.ancestors,
this.descendants,
this.forceSensitive = false,
});
final PostModel originPostModel;
final Map<String, PostModel>? identities;
final bool hidden;
final List<PostModel>? ancestors;
final List<PostModel>? descendants;
final bool forceSensitive;
@override
State<FullPostView> createState() => _FullPostViewState();
}
class _FullPostViewState extends State<FullPostView> {
List<PostModel> ancestors = [];
List<PostModel> descendants = [];
Map<String, PostModel> identities = {};
String activeIdentity = "";
int idsChecked = 1;
bool sensitive = false;
void loadIdentities() async {
global.settings!.identities.forEach((key, value) async {
if (!identities.containsKey(key)) {
final r = await getPostFromUrl(key, widget.originPostModel.uri);
if (r.values.first != null && mounted) {
setState(() {
identities.addAll({key: r.values.first!});
});
}
if (mounted) {
setState(() {
idsChecked++;
});
}
}
});
}
void loadPosts() async {
if (widget.ancestors == null || widget.descendants == null) {
final r = await getContextForPost(widget.originPostModel);
if (r.value != null && mounted) {
setState(() {
ancestors = r.value!.ancestors;
descendants = r.value!.descendants;
});
}
for (var a in ancestors) {
if (a.sensitive) {
sensitive = true;
}
}
for (var d in descendants) {
if (d.sensitive) {
sensitive = true;
}
}
}
}
@override
void initState() {
if (widget.ancestors != null) {
ancestors = widget.ancestors!;
}
if (widget.descendants != null) {
descendants = widget.descendants!;
}
sensitive = widget.originPostModel.sensitive;
if (widget.identities != null) {
idsChecked = global.settings!.identities.length;
}
identities = widget.identities ?? {};
identities.addAll({
widget.originPostModel.identity: widget.originPostModel,
});
activeIdentity = widget.originPostModel.identity;
loadPosts();
loadIdentities();
super.initState();
}
@override
Widget build(BuildContext context) {
List<DropdownMenuItem<String>> dropdownButtons = [];
identities.forEach((key, value) {
dropdownButtons.add(
DropdownMenuItem(
alignment: Alignment.center,
value: key,
child: Text(key, style: Theme.of(context).textTheme.bodyMedium),
),
);
});
return BackdropFilter(
filter:
ImageFilter.blur(sigmaX: 10, sigmaY: 10, tileMode: TileMode.mirror),
child: SimpleDialog(
contentPadding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
children: [
global.settings!.identities.length > idsChecked
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SelectableText(
"${"post-found-on".i18n()} $idsChecked ${idsChecked == 1 ? "instance".i18n() : "instances".i18n()}",
textAlign: TextAlign.center,
),
const LinearProgressIndicator(),
],
)
: Padding(
padding: themes.defaultInsideMargins,
child: Wrap(
alignment: WrapAlignment.spaceAround,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
DropdownButtonHideUnderline(
child: DropdownButton<String>(
alignment: Alignment.center,
value: activeIdentity,
style: Theme.of(context).textTheme.bodyMedium,
iconEnabledColor:
Theme.of(context).colorScheme.onSurface,
items: dropdownButtons,
onChanged: (value) {
setState(() {
Navigator.of(context).pop();
showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) => FullPostView(
originPostModel: identities[value]!,
identities: identities,
),
);
});
loadPosts();
},
),
),
if (sensitive || widget.forceSensitive)
ElevatedButton.icon(
onPressed: () {
setState(() {
Navigator.of(context).pop();
showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) => FullPostView(
originPostModel: widget.originPostModel,
hidden: !widget.hidden,
identities: identities,
ancestors: ancestors,
descendants: descendants,
forceSensitive: true,
),
);
});
},
icon: Icon(widget.hidden
? Icons.visibility
: Icons.visibility_off),
label: Text(
widget.hidden ? "show".i18n() : "hide".i18n(),
),
),
],
),
),
Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 2 / 3,
maxWidth: global.getConstraints(context).maxWidth),
width: global.getWidth(context),
child: SingleChildScrollView(
child: Padding(
padding: themes.defaultMargins,
child: SingleFullPostDisplay(
ancestors: ancestors,
descendants: descendants,
hidden: widget.hidden,
level: 0,
model: identities[activeIdentity]!.reblog ??
identities[activeIdentity]!,
),
),
),
)
],
),
);
}
}
class SingleFullPostDisplay extends StatelessWidget {
const SingleFullPostDisplay({
super.key,
required this.level,
required this.model,
required this.ancestors,
required this.descendants,
this.hidden = true,
});
final int level;
final PostModel model;
final List<PostModel> ancestors;
final List<PostModel> descendants;
final bool hidden;
@override
Widget build(BuildContext context) {
List<Post> ancestorWidgets = ancestors
.map(
(e) => Post(model: e, hideSensitive: hidden),
)
.toList();
List<Widget> descendantsWidgets = [];
// seems most efficient
// considering that lists aren't v long
for (var element in descendants) {
if (element.inReplyTo == model.id) {
descendantsWidgets.add(SingleFullPostDisplay(
level: level + 1,
model: element,
ancestors: const [],
descendants: descendants,
hidden: hidden,
));
}
}
List<Widget> c = [];
c.addAll(ancestorWidgets);
c.add(Post(
model: model,
hideSensitive: hidden,
));
c.addAll(descendantsWidgets);
return Container(
padding: EdgeInsets.fromLTRB(
level == 0 ? 0 : 4,
themes.defaultSeperatorHeight,
level == 0 ? 0 : 4,
themes.defaultSeperatorHeight),
decoration: BoxDecoration(
border: level == 0
? const Border()
: Border(
left: BorderSide(
width: 4,
color: level.isEven
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.primary,
),
),
),
child: Column(children: c));
}
}

View File

@ -1,11 +1,19 @@
import 'dart:convert';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/fileupload/fileupload.dart';
import 'package:loris/business_logic/instance/instance.dart';
import 'package:loris/business_logic/network_tools/get_post_from_url.dart';
import 'package:loris/business_logic/timeline/timeline.dart' as tl;
import '../business_logic/posting/posting.dart';
import '../partials/post.dart';
import 'package:loris/global.dart' as global;
import 'package:file_picker/file_picker.dart';
import 'package:mime/mime.dart';
import 'package:universal_io/io.dart' as io;
import 'package:loris/themes/themes.dart' as themes;
class MakePost extends StatefulWidget {
const MakePost({Key? key, this.inReplyTo}) : super(key: key);
@ -18,26 +26,59 @@ class MakePost extends StatefulWidget {
class _MakePostState extends State<MakePost> {
String replyAts = "";
String accountid = global.settings!.activeIdentity;
int? maxLength;
InstanceInformation? instanceInfo;
String text = "";
String spoilerText = "";
String? status;
bool sending = false;
// tracks fileuploads and their ids on servers
// if the id is null the file has not been uploaded yet
List<FileUpload> files = [];
// stores all identities and if available the post id you are replying to
Map<String, String?> identitiesAvailable = {};
tl.Visibility visibility = tl.Visibility.public;
void switchAccount(String acct) {
setState(() {
maxLength = null;
instanceInfo = null;
accountid = acct;
});
updateMaxChars();
}
// check if there are more attachments than the instance ur posting to allowss
bool tooManyAttachments() {
if (instanceInfo == null) {
return true;
}
if (instanceInfo!.configuration.statusconfig.maxMediaAttachments <
files.length) {
return true;
}
return false;
}
// moves file in list up or down,
// has to be called with file from items list
void moveFile(FileUpload file, {up = true}) {
if (!mounted) {
return;
}
final index = files.indexOf(file);
final newIndex = (index + (up ? -1 : 1)) % files.length;
files.remove(file);
setState(() {
files.insert(newIndex, file);
});
}
void updateMaxChars() async {
final info = await instanceInformationForIdentity(accountid);
if (info.keys.first == 200) {
if (info.keys.first == 200 && mounted) {
setState(() {
maxLength = info.values.first!.configuration.statusconfig.maxChars;
instanceInfo = info.values.first!;
});
}
}
@ -59,7 +100,10 @@ class _MakePostState extends State<MakePost> {
void addAt(String at) {
if (!at.contains("@")) {
at = "$at${widget.inReplyTo!.getReceiverInstance()} ";
at = "$at${widget.inReplyTo!.getReceiverInstance()}";
}
if (global.settings!.identities.keys.contains(at)) {
return;
}
replyAts = "$replyAts@$at ";
}
@ -67,6 +111,7 @@ class _MakePostState extends State<MakePost> {
@override
void initState() {
if (widget.inReplyTo != null) {
visibility = widget.inReplyTo!.visibility;
addAt(widget.inReplyTo!.account.acct);
for (var element in widget.inReplyTo!.mentions) {
addAt(element.acct);
@ -96,6 +141,7 @@ class _MakePostState extends State<MakePost> {
@override
Widget build(BuildContext context) {
// make list of all different widgets to be displayed
List<Widget> c = [];
if (widget.inReplyTo != null) {
c.add(
@ -136,9 +182,6 @@ class _MakePostState extends State<MakePost> {
)));
}
List<Widget> actionButtons = [
maxLength == null
? const CircularProgressIndicator()
: SelectableText((maxLength! - text.length).toString()),
DropdownButtonHideUnderline(
child: DropdownButton<tl.Visibility>(
alignment: Alignment.center,
@ -152,6 +195,31 @@ class _MakePostState extends State<MakePost> {
},
),
),
// media attachment button
TextButton.icon(
onPressed: () async {
// open filepicker
FilePickerResult? result = await FilePicker.platform.pickFiles(
withData: false,
allowMultiple: true,
);
if (result != null) {
// if there are any files
// then parse them
for (var path in result.paths) {
if (path != null && mounted) {
if (await io.Directory(path).exists()) break;
final FileUpload f = await FileUpload.fromPath(path, "");
setState(() {
files.add(f);
});
}
}
}
},
icon: const Icon(Icons.attachment),
label: Text(
"${"add-files".i18n()}${instanceInfo == null ? "(${"loading...".i18n()})" : " (${instanceInfo!.configuration.statusconfig.maxMediaAttachments - files.length} ${"remaining".i18n()})"}")),
DropdownButtonHideUnderline(
child: DropdownButton<String>(
alignment: Alignment.center,
@ -164,32 +232,79 @@ class _MakePostState extends State<MakePost> {
},
),
),
OutlinedButton.icon(
ElevatedButton.icon(
label: Text(
"send-post".i18n(),
),
// send the post!!!
onPressed: () async {
final model = MakePostModel(
spoilerText: spoilerText,
identity: accountid,
status: text,
visibility: visibility,
inReplyToId: widget.inReplyTo == null
? null
: identitiesAvailable[accountid]!,
);
model.sendPost();
Navigator.of(context).pop();
},
onPressed: ((spoilerText.isEmpty && text.isEmpty && files.isEmpty) ||
tooManyAttachments())
? null
: () async {
setState(() {
sending = true;
status = "sending...".i18n();
});
// upload the media attachments
List<String> mediaIds = [];
for (var file in files) {
if (mounted) {
setState(() {
status =
"${"sending-file".i18n()} ${mediaIds.length + 1}/${files.length}";
});
}
final response = await file.upload(accountid);
if (response.key == 200 || response.key == 202) {
mediaIds.add(response.value);
} else if (mounted) {
setState(() {
status = response.value;
sending = false;
});
return;
}
}
// send the final post
final model = MakePostModel(
mediaIds: mediaIds,
spoilerText: spoilerText.trim(),
identity: accountid,
status: text,
visibility: visibility,
inReplyToId: widget.inReplyTo == null
? null
: identitiesAvailable[accountid]!,
);
final result = await model.sendPost();
if (mounted) {
if (result.statusCode == 200) {
Navigator.of(context).pop();
}
setState(() {
sending = false;
status =
"${"failed-to-send".i18n()}: ${jsonDecode(result.body)["error"].toString()}";
});
}
},
icon: const Icon(Icons.send),
),
];
c.addAll([
// content warnings
TextFormField(
style: Theme.of(context).textTheme.bodyMedium,
initialValue: spoilerText,
decoration: InputDecoration(
counterText: instanceInfo == null
? "loading...".i18n()
: (instanceInfo!.configuration.statusconfig.maxChars -
text.length -
spoilerText.length)
.toString(),
prefixIcon: Icon(
Icons.warning,
color: Theme.of(context).colorScheme.onSurface,
@ -200,9 +315,11 @@ class _MakePostState extends State<MakePost> {
spoilerText = value;
})),
),
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
Container(
constraints:
BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.6),
child: TextFormField(
autofocus: true,
initialValue: replyAts,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: null,
@ -217,6 +334,10 @@ class _MakePostState extends State<MakePost> {
const SizedBox(
height: 24,
),
]);
// these are the action buttons
c.add(
Wrap(
runSpacing: 8,
spacing: 24,
@ -225,32 +346,113 @@ class _MakePostState extends State<MakePost> {
alignment: WrapAlignment.spaceAround,
children: actionButtons,
),
]);
);
return SimpleDialog(
alignment: Alignment.center,
contentPadding: const EdgeInsets.all(24),
title: SelectableText(
textAlign: TextAlign.center,
widget.inReplyTo == null ? "make-post".i18n() : "make-reply".i18n(),
style: Theme.of(context).textTheme.displayMedium),
children: [
SingleChildScrollView(
child: Container(
width: (MediaQuery.of(context).size.width *
global.settings!.postWidth) -
56,
constraints: BoxConstraints(
maxWidth: global.settings!.maxPostWidth,
minWidth: 375,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: c,
// display media attachments
for (var file in files) {
c.add(Column(
children: [
FileUploadDisplay(file: file),
TextField(
onChanged: (value) => file.description = value,
style: Theme.of(context).textTheme.bodyMedium,
minLines: 1,
maxLines: null,
decoration: InputDecoration(
hintText: "file-description".i18n(),
),
),
Wrap(
children: [
TextButton.icon(
onPressed: () {
moveFile(file, up: false);
},
icon: const Icon(Icons.move_down),
label: Text("move-down".i18n()),
),
TextButton.icon(
onPressed: () => setState(() {
files.remove(file);
}),
icon: const Icon(Icons.delete),
label: Text(
"remove-attached-file".i18n(),
),
),
TextButton.icon(
onPressed: () {
moveFile(file);
},
icon: const Icon(Icons.move_up),
label: Text("move-up".i18n()),
),
],
),
],
));
}
return BackdropFilter(
filter:
ImageFilter.blur(sigmaX: 10, sigmaY: 10, tileMode: TileMode.mirror),
child: SimpleDialog(
titlePadding: const EdgeInsets.all(0),
alignment: Alignment.center,
contentPadding: themes.defaultMargins,
title: Column(
children: [
if (status != null) SelectableText(status!),
if (sending) const LinearProgressIndicator(),
],
),
children: [
SingleChildScrollView(
child: Container(
width: (MediaQuery.of(context).size.width *
global.settings!.postWidth) -
56,
constraints: global.getConstraints(context),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: c,
),
),
),
],
),
);
}
}
class FileUploadDisplay extends StatelessWidget {
const FileUploadDisplay({super.key, required this.file});
final FileUpload file;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Divider(
color: Theme.of(context).hintColor,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.attachment, size: 32),
Expanded(
child: SelectableText(file.path,
style: Theme.of(context).textTheme.displaySmall),
),
],
),
if (lookupMimeType(file.path)?.startsWith("image/") ?? false)
Image.asset(
file.path,
fit: BoxFit.fitWidth,
),
],
);
}

View File

@ -0,0 +1,453 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/account/account.dart';
import 'package:loris/business_logic/settings.dart';
import 'package:loris/business_logic/timeline/timeline.dart';
import 'package:loris/partials/loadingbox.dart';
import 'package:loris/partials/post.dart';
import 'package:loris/partials/post_text_renderer.dart';
import 'package:loris/global.dart' as global;
import 'package:loris/partials/thread.dart';
import 'package:loris/themes/themes.dart' as themes;
class ProfileView extends StatefulWidget {
const ProfileView({
super.key,
required this.model,
});
final AccountModel model;
@override
State<ProfileView> createState() => _ProfileViewState();
}
class _ProfileViewState extends State<ProfileView> {
final StreamController<MapEntry<String, RelationshipModel>>
_relationshipStream = StreamController();
Map<String, AccountModel> identities = {};
Map<String, RelationshipModel?> relationships = {};
String activeIdentity = "";
bool loading = true;
List<ThreadModel> threads = [];
String? maxid;
final ScrollController _scrollController = ScrollController();
bool loadingPosts = false;
Future<void> addRelationship(AccountModel m) async {
final r = await getRelationship(m.identity, m.id);
if (r.keys.first != 200) {
return;
} else if (mounted) {
setState(() {
relationships.addAll({
m.identity: r.values.first,
});
});
}
}
Future<void> addIdentity(String identityName, int i) async {
final m = await searchModel(identityName, widget.model.url);
if (m.values.first != null) {
if (mounted) {
setState(() {
identities.addAll({identityName: m.values.first!});
});
}
await addRelationship(m.values.first!);
}
if (i >= global.settings!.identities.length - 2) {
if (mounted) {
setState(() {
loading = false;
});
}
}
}
void loadPosts() async {
if (!loadingPosts) {
loadingPosts = true;
if (threads.isNotEmpty) {
maxid = threads.last.posts.last.id;
}
final t = await getPostsForAccount(identities[activeIdentity]!, maxid);
if (t.value == null) {
return;
}
if (mounted) {
setState(() {
threads.addAll(t.value!);
});
}
loadingPosts = false;
}
}
void reloadPosts() {
_scrollController.animateTo(0,
duration: const Duration(seconds: 1), curve: Curves.ease);
setState(() {
maxid = null;
threads = [];
});
loadPosts();
}
void update() async {
int i = 0;
await Future.forEach<MapEntry<String, AccountSettings>>(
global.settings!.identities.entries,
(element) async {
if (element.key == widget.model.identity) {
return;
}
await addIdentity(element.key, i);
i++;
},
);
}
@override
void dispose() {
_scrollController.dispose();
_relationshipStream.close();
super.dispose();
}
@override
void initState() {
if (global.settings!.identities.length == 1) {
loading = false;
}
activeIdentity = widget.model.identity;
identities.addAll({widget.model.identity: widget.model});
addRelationship(widget.model);
update();
loadPosts();
_scrollController.addListener(() {
if (_scrollController.offset >=
_scrollController.position.maxScrollExtent -
MediaQuery.of(context).size.height &&
!_scrollController.position.outOfRange) {
loadPosts();
}
});
_relationshipStream.stream.listen((event) {
setState(() {
relationships.addEntries([event]);
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
List<DropdownMenuItem<String>> dmenuItems = [];
identities.forEach((key, value) {
dmenuItems.add(
DropdownMenuItem(
alignment: Alignment.center,
value: key,
child: Text(
key,
style: Theme.of(context).textTheme.bodyMedium,
),
),
);
});
final profileViewDisplay = Padding(
padding: const EdgeInsets.all(0),
child: ProfileViewDisplay(
accountModel: identities[activeIdentity]!,
relationshipModel: relationships[activeIdentity],
relationshipStream: _relationshipStream,
),
);
return BackdropFilter(
filter:
ImageFilter.blur(sigmaX: 10, sigmaY: 10, tileMode: TileMode.mirror),
child: SimpleDialog(
alignment: Alignment.center,
title: DisplayName(
account: identities[activeIdentity]!,
openInBrowser: true,
),
contentPadding: const EdgeInsets.all(0),
children: [
Container(
constraints: global.getConstraints(context),
child: Column(children: [
loading
? Column(
children: [
SelectableText(
"${"found-account-on".i18n()} ${identities.length} ${identities.length <= 1 ? "instance" : "instances".i18n()}"),
const LinearProgressIndicator(),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
DropdownButtonHideUnderline(
child: DropdownButton(
isExpanded: false,
alignment: Alignment.center,
iconEnabledColor:
Theme.of(context).colorScheme.onSurface,
value: activeIdentity,
items: dmenuItems,
onChanged: (value) {
setState(() {
activeIdentity = value.toString();
});
reloadPosts();
},
),
),
],
),
SizedBox(
// constraints: global.getConstraints(context),
width: global.getWidth(context),
height: MediaQuery.of(context).size.height * 2 / 3,
child: ListView.separated(
separatorBuilder: (context, index) => const Divider(
color: Colors.transparent,
height: themes.defaultSeperatorHeight,
),
controller: _scrollController,
itemCount: threads.length + 2,
itemBuilder: (context, index) {
if (index == 0) {
return profileViewDisplay;
} else if (index > 0 && index <= threads.length) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: themes.defaultSeperatorHeight * 2),
child: Thread(
model: threads[index - 1],
constrained: false,
),
);
}
return const LoadingBox();
},
),
),
]),
),
],
),
);
}
}
class AccountInteractionButtons extends StatelessWidget {
const AccountInteractionButtons({
super.key,
required this.account,
required this.relationship,
required this.stream,
});
final AccountModel account;
final RelationshipModel relationship;
final StreamController stream;
@override
Widget build(BuildContext context) {
return Wrap(
children: [
AccountInteractionButton(
accountModel: account,
relationshipModel: relationship,
type: AccountInteractionTypes.follow,
stream: stream,
),
AccountInteractionButton(
accountModel: account,
relationshipModel: relationship,
type: AccountInteractionTypes.block,
stream: stream,
),
],
);
}
}
class StatusIndicators extends StatelessWidget {
const StatusIndicators({
super.key,
required this.model,
this.relationship,
});
final AccountModel model;
final RelationshipModel? relationship;
List<InlineSpan> getTextWithIcon(IconData icon, String t) {
return [
WidgetSpan(
child: Icon(icon),
alignment: PlaceholderAlignment.middle,
),
TextSpan(
text: t,
)
];
}
@override
Widget build(BuildContext context) {
List<InlineSpan> c = [];
if (relationship == null) {
c.add(const WidgetSpan(child: LinearProgressIndicator()));
} else {
// follow relationship
if (relationship!.followedBy && relationship!.following) {
c.addAll(getTextWithIcon(Icons.group, "you-are-mufos".i18n()));
} else if (relationship!.followedBy) {
c.addAll(getTextWithIcon(Icons.group, "they-follow-you".i18n()));
} else if (relationship!.following) {
c.addAll(getTextWithIcon(Icons.group, "you-follow-them".i18n()));
} else {
c.addAll(getTextWithIcon(
Icons.group, "you-do-not-follow-each-other".i18n()));
}
if (relationship!.requested) {
c.addAll(
getTextWithIcon(Icons.group_add, "pending-follow-request".i18n()));
}
}
// account is a bot
if (model.bot != null) {
if (model.bot!) {
c.addAll(getTextWithIcon(Icons.smart_toy, "user-is-bot".i18n()));
}
}
return SelectableText.rich(
TextSpan(children: c),
);
}
}
class ProfileViewDisplay extends StatelessWidget {
const ProfileViewDisplay({
super.key,
required this.accountModel,
this.relationshipModel,
required this.relationshipStream,
});
final AccountModel accountModel;
final RelationshipModel? relationshipModel;
final StreamController relationshipStream;
static const d = SizedBox(
height: 8,
);
@override
Widget build(BuildContext context) {
List<Widget> c = [
if (!(accountModel.header.endsWith("/headers/original/missing.png") ||
(accountModel.header.endsWith("default_header.png"))))
Image.network(
fit: BoxFit.fitWidth,
accountModel.header,
errorBuilder: (context, error, stackTrace) => const SizedBox.shrink(),
),
Padding(
padding: themes.defaultMargins,
child: StatusIndicators(
model: accountModel,
relationship: relationshipModel,
),
),
d,
Padding(
padding: themes.defaultMargins,
child: PostTextRenderer(
input: accountModel.note,
identityName: accountModel.identity,
emoji: accountModel.emojis,
),
),
if (relationshipModel != null)
Padding(
padding: const EdgeInsets.all(themes.defaultSeperatorHeight),
child: AccountInteractionButtons(
account: accountModel,
relationship: relationshipModel!,
stream: relationshipStream,
),
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: c,
);
}
}
class AccountInteractionButton extends StatefulWidget {
const AccountInteractionButton({
super.key,
required this.type,
required this.relationshipModel,
required this.accountModel,
required this.stream,
});
final AccountInteractionTypes type;
final RelationshipModel relationshipModel;
final AccountModel accountModel;
final StreamController stream;
@override
State<AccountInteractionButton> createState() =>
AccountInteractionButtonState();
}
class AccountInteractionButtonState extends State<AccountInteractionButton> {
bool active() {
switch (widget.type) {
case AccountInteractionTypes.follow:
return widget.relationshipModel.following;
case AccountInteractionTypes.block:
return widget.relationshipModel.blocking;
case AccountInteractionTypes.mute:
return widget.relationshipModel.muting;
}
}
@override
Widget build(BuildContext context) {
return TextButton.icon(
onPressed: () async {
final r = await performInteraction(
widget.relationshipModel.identity,
widget.accountModel.id,
widget.type,
active(),
);
if (r.value == null) {
return;
}
widget.stream
.add(MapEntry(widget.relationshipModel.identity, r.value!));
},
icon: Icon(widget.type.icon),
label: Text(
active() ? widget.type.revokeSlug.i18n() : widget.type.slug.i18n(),
),
);
}
}

View File

@ -1,13 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:loris/business_logic/settings.dart';
import 'themes/themes.dart' as themes;
const String name = "loris";
const String version = "v0.2 'the themes update'";
const String version = "v0.5 'the halloween update'";
const String useragent = "$name/$version";
const String website = "https://git.kittycat.homes/zoe/loris";
const Map<String, String> defaultHeaders = {
"User-Agent": useragent,
final Map<String, String> defaultHeaders = {
if (!kIsWeb) "User-Agent": useragent,
"accept": "application/json",
"Content-Type": "application/json"
};
@ -19,9 +21,20 @@ const List<String> bad = [
"poa.st",
"gleasonator.com",
"shitposter.club",
"freespeechextremist.com"
"freespeechextremist.com",
];
double getWidth(context) {
return (MediaQuery.of(context).size.width * settings!.postWidth) -
(themes.defaultSeperatorHeight * 2);
}
BoxConstraints getConstraints(context) {
return BoxConstraints(
maxWidth: settings!.maxPostWidth,
);
}
const List<Locale> availableLocales = [Locale("en", "US"), Locale("de")];
Settings? settings;

View File

@ -15,6 +15,7 @@
"media-not-supported": "media type not supported",
"show-about-page": "show about page",
"about": "about",
"load-more": "load more",
"account-settings": "account settings",
"logout": "log out",
"show-in-browser": "show in browser",
@ -52,6 +53,45 @@
"make-post": "make post",
"content-warning": "content warning",
"theme-title": "theme",
"send-post": "computer, send post"
"send-post": "computer, send post",
"jacking-in": "jacking in...",
"jack-in": "jack in",
"add-account": "add account",
"day-1": "monday",
"day-2": "tuesday",
"day-3": "wednesday",
"day-4": "thursday",
"day-5": "friday",
"day-6": "saturday",
"day-7": "sunday",
"user-is-bot": "this account is a bot",
"you-are-mufos": "you are mutuals",
"they-follow-you": "they follow you",
"you-follow-them": "you follow them",
"you-do-not-follow-each-other": "you don't follow each other",
"found-account-on": "found account on",
"instances": "instances",
"instance": "instace",
"post-found-on": "found post on",
"show-in-full": "show in full",
"block": "banish",
"unblock": "unbanish",
"add-files": "add files",
"remaining": "remaining",
"move-down": "move down",
"move-up": "move up",
"remove-attached-file": "remove attachment",
"file-description": "file description",
"loading...": "loading",
"failed-to-send": "failed to send!",
"sending-file": "sending file",
"open-media-in-browser": "open media in browser",
"unread": "unread",
"expand": "expand",
"requested-to-follow": "has requested to follow:",
"follow-requests": "follow requests",
"post-deleted": "post deleted",
"delete-this": "delete this",
"deletion-failed": "deletion failed"
}

View File

@ -3,16 +3,19 @@ import 'package:intl/intl.dart';
import 'package:localization/localization.dart';
import 'package:loris/pages/settings/app.dart';
import 'package:loris/partials/main_scaffold.dart';
import 'pages/login.dart';
import 'pages/login/login.dart';
import 'business_logic/settings.dart' as settings;
import 'package:flutter_localizations/flutter_localizations.dart';
import 'themes/themes.dart' as themes;
import 'global.dart' as global;
import 'package:url_strategy/url_strategy.dart';
ThemeData theme = themes.getTheme(themes.available[1]);
Locale activeLocale = const Locale("en_US");
void main() async {
setPathUrlStrategy();
WidgetsFlutterBinding.ensureInitialized();
Intl.defaultLocale = "en_US";
global.settings = await settings.Settings.create();
activeLocale = global.settings!.locale;
@ -58,10 +61,29 @@ class _LorisState extends State<Loris> {
LocalJsonLocalization.delegate,
],
initialRoute: global.settings!.identities.isEmpty ? "/login" : "/",
routes: {
'/': (context) => const MainScaffold(),
'/login': (context) => const Login(),
},
onGenerateRoute: (s) => RouterGenerator.generateRoute(s),
);
}
}
class RouterGenerator {
static Route<dynamic> generateRoute(RouteSettings s) {
var routingData = s.name;
switch (routingData) {
case "/login":
return MaterialPageRoute(
builder: (context) {
return const Login();
},
settings: s,
);
default:
return MaterialPageRoute(
builder: (context) {
return const MainScaffold();
},
settings: s,
);
}
}
}

View File

@ -1,5 +0,0 @@
import 'package:flutter/widgets.dart';
Widget chat(context) {
return const Center(child: Text("Chat"));
}

View File

@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import '../business_logic/auth/oauth.dart' as oauth;
import 'package:loris/pages/login/weblogin.dart';
import '../../business_logic/auth/oauth.dart' as oauth;
class Login extends StatefulWidget {
const Login({Key? key}) : super(key: key);
@ -67,11 +69,20 @@ class _LoginFormState extends State<LoginForm> {
);
} else {
formKey.currentState?.save();
Navigator.push(
if (kIsWeb) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => Weblogin(url: instanceUrl),
));
} else {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => AuthPage(url: instanceUrl),
));
),
);
}
}
},
icon: const Icon(Icons.login),

View File

@ -0,0 +1,103 @@
// oauth page for web only
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:universal_html/html.dart' as html;
import 'package:loris/business_logic/auth/oauth.dart' as oauth;
class Weblogin extends StatefulWidget {
const Weblogin({Key? key, required this.url}) : super(key: key);
final String url;
@override
State<Weblogin> createState() => _WebloginState();
}
class _WebloginState extends State<Weblogin> {
bool doneLoading = false;
void doAuth() async {
final appresponse = await oauth.registerApp(widget.url);
if (appresponse.statusCode != 200) {
informAboutFailure(appresponse.statusCode);
return;
}
final app = oauth.App.fromJson(jsonDecode(appresponse.body));
html.window.open(
oauth.getAuthUrl(widget.url, app).toString(),
"_blank",
);
html.window.onMessage.listen((event) async {
final uri = Uri.parse(event.data);
if (uri.queryParameters.containsKey("code")) {
//ignore: ivalid_null_aware_operator
//popupWin.close();
final token = await oauth.getToken(
uri.queryParameters["code"]!,
app.clientId,
app.clientSecret,
widget.url,
);
await oauth.saveIdentity(token, widget.url, app.clientId,
app.clientSecret, uri.queryParameters["code"]!);
setState(() {
doneLoading = true;
});
}
});
}
void informAboutFailure(int i) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text("error: $i")));
}
@override
void initState() {
super.initState();
doAuth();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
SelectableText(widget.url),
doneLoading
? TextButton.icon(
onPressed: () {
Navigator.of(context).pushReplacementNamed("/");
},
icon: const Icon(
Icons.navigate_next,
),
label: Text(
"jack-in".i18n(),
),
)
: const LoadingIndicator(),
],
),
);
}
}
class LoadingIndicator extends StatelessWidget {
const LoadingIndicator({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Wrap(
alignment: WrapAlignment.center,
spacing: 24,
children: [
const CircularProgressIndicator(),
SelectableText("jacking-in".i18n()),
],
);
}
}

View File

@ -7,6 +7,7 @@ import 'package:loris/business_logic/notifications/notifs.dart';
import 'package:loris/pages/notifications/single_notif.dart';
import 'package:loris/partials/loadingbox.dart';
import '../../business_logic/websocket.dart' as websocket;
import 'package:loris/themes/themes.dart' as themes;
final notifStream = StreamController<int>.broadcast();
@ -111,30 +112,41 @@ class _NotificationsState extends State<Notifications> {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
color: Theme.of(context).colorScheme.surface,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
onPressed: () {
reload();
},
icon: const Icon(Icons.refresh))
],
),
),
Expanded(
child: ListView.separated(
controller: _controller,
addSemanticIndexes: true,
itemBuilder: ((context, index) => notifs[index]),
separatorBuilder: (context, index) => const SizedBox(
height: 8,
separatorBuilder: (context, index) => const Divider(
height: themes.defaultSeperatorHeight,
color: Colors.transparent,
),
itemCount: notifs.length),
),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
width: 2,
color: Theme.of(context).colorScheme.primary,
))),
child: Material(
child: Wrap(
spacing: 24,
alignment: WrapAlignment.spaceEvenly,
children: [
IconButton(
onPressed: () {
reload();
},
icon: const Icon(Icons.refresh))
],
),
),
),
],
);
}

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:loris/business_logic/notifications/notifs.dart';
import 'package:loris/dialogues/profile_view.dart';
import 'package:loris/partials/name.dart';
import 'package:loris/partials/post.dart';
import '../../global.dart' as global;
import 'package:loris/themes/themes.dart' as themes;
class SingleNotif extends StatelessWidget {
const SingleNotif({
@ -12,60 +15,67 @@ class SingleNotif extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Align(
child: Container(
width:
(MediaQuery.of(context).size.width * global.settings!.postWidth) -
56,
constraints: BoxConstraints(
maxWidth: global.settings!.maxPostWidth,
minWidth: 375,
),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(color: Theme.of(context).colorScheme.secondary),
borderRadius: BorderRadius.circular(8),
return Align(
child: Container(
width: global.getWidth(context),
constraints: global.getConstraints(context),
padding: themes.defaultInsideMargins,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
width: 2,
),
borderRadius: const BorderRadius.all(themes.defaultRadius),
),
child: Material(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ProfilePic(url: model.account.avatar),
const SizedBox(
width: 8,
),
Expanded(
flex: 20,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
model.account.displayName,
style: Theme.of(context).textTheme.displaySmall,
),
SelectableText.rich(
TextSpan(
text: "${model.account.acct} ",
style: Theme.of(context).textTheme.bodySmall,
children: [
TextSpan(
text: model.type.actionName,
style: Theme.of(context).textTheme.bodyMedium)
],
),
),
],
InkWell(
borderRadius: const BorderRadius.all(themes.defaultRadius),
onTap: () => showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) => ProfileView(model: model.account),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ProfilePic(url: model.account.avatar),
const SizedBox(
width: 8,
),
),
Icon(
model.type.icon,
size: 64,
),
],
Expanded(
flex: 20,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NameDisplay(
emoji: model.account.emojis,
content: model.account.displayName,
style: Theme.of(context).textTheme.displaySmall!,
),
SelectableText.rich(
TextSpan(
text: "${model.account.acct} ",
style: Theme.of(context).textTheme.bodySmall,
children: [
TextSpan(
text: model.type.actionName,
style:
Theme.of(context).textTheme.bodyMedium)
],
),
),
],
),
),
Icon(
model.type.icon,
size: 64,
),
],
),
),
const SizedBox(
height: 8,

View File

@ -0,0 +1,135 @@
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 'package:loris/themes/themes.dart' as themes;
import '../../partials/post.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
String searchText = "";
String searchTextControl = "";
Map<String, logic.SearchResult> 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<AccountModel> accountModels = [];
List<PostModel> postModels = [];
results.forEach(
(key, value) {
for (var m in value.accountModels) {
accountModels.add(m);
}
for (var m in value.postModels) {
postModels.add(m);
}
},
);
return Material(
child: Column(
children: [
Expanded(
child: ListView.builder(
shrinkWrap: true,
itemCount: accountModels.length + postModels.length,
itemBuilder: (context, index) {
if (index < accountModels.length) {
final model = accountModels[index];
return Container(
width: global.getWidth(context),
constraints: global.getConstraints(context),
child: Padding(
padding: themes.defaultMargins,
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,
)
],
),
);
}),
),
Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2))),
child: 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: InputDecoration(
prefixIcon: Icon(
color: Theme.of(context).colorScheme.primary,
Icons.search,
))),
if (searched < global.settings!.identities.length &&
searchTextControl.isNotEmpty)
Column(
children: [
SelectableText(
"${"searched".i18n()} $searched ${searched == 1 ? "instance".i18n() : "instances".i18n()}"),
const LinearProgressIndicator(),
],
),
],
),
),
],
),
);
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/dialogues/conversations.dart';
import 'package:loris/dialogues/followreqs.dart';
import '../../global.dart' as global;
class AccountSettings extends StatelessWidget {
@ -13,6 +15,8 @@ class AccountSettings extends StatelessWidget {
LogoutButton(identity: global.settings!.identities.keys.toList()[i]));
}
children.add(const NewAccountButton());
children.add(const FollowreqButton());
children.add(const ConversationButton());
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
@ -29,7 +33,7 @@ class LogoutButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
return Wrap(
children: [
SelectableText(identity),
TextButton.icon(
@ -73,3 +77,37 @@ Future<void> logout(context, String identity) async {
(Navigator.of(context).pushReplacementNamed("/"));
}
}
class FollowreqButton extends StatelessWidget {
const FollowreqButton({super.key});
@override
Widget build(BuildContext context) {
return TextButton.icon(
onPressed: (() => showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) => const FollowReqScreen(),
)),
icon: const Icon(Icons.check),
label: Text(
"follow-requests".i18n(),
),
);
}
}
class ConversationButton extends StatelessWidget {
const ConversationButton({super.key});
@override
Widget build(BuildContext context) {
return TextButton.icon(
onPressed: () => showDialog(
context: context,
builder: (context) => const Chat(),
),
icon: const Icon(Icons.chat),
label: Text("chat".i18n()));
}
}

View File

@ -3,6 +3,8 @@ import 'package:localization/localization.dart';
import './account.dart' as account;
import './about.dart' as about;
import './app.dart' as app;
import 'package:loris/global.dart' as global;
import 'package:loris/themes/themes.dart' as themes;
Widget settings(context) {
final List<Widget> categories = [
@ -16,17 +18,40 @@ Widget settings(context) {
content: const about.AboutSettings(),
)
];
return ListView.separated(
itemBuilder: (context, index) {
return categories[index];
},
separatorBuilder: (context, index) {
return const Divider(
height: 0,
color: Colors.transparent,
);
},
itemCount: categories.length);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ListView.separated(
shrinkWrap: true,
itemBuilder: (context, index) {
return categories[index];
},
separatorBuilder: (context, index) {
return const Divider(
height: 0,
color: Colors.transparent,
);
},
itemCount: categories.length),
),
Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
),
child: Material(
child: Wrap(
alignment: WrapAlignment.center,
children: const [],
),
)),
],
);
}
class SettingsPanel extends StatelessWidget {
@ -42,20 +67,26 @@ class SettingsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.symmetric(
horizontal: themes.defaultSeperatorHeight * 2,
vertical: themes.defaultSeperatorHeight),
child: Container(
constraints: global.getConstraints(context),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(color: Theme.of(context).colorScheme.secondary),
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
SelectableText(
title,
style: Theme.of(context).textTheme.headlineMedium,
style: Theme.of(context).textTheme.displayMedium,
),
content,
],

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/dialogues/makepost.dart';
import 'package:loris/partials/thread.dart';
import '../../business_logic/timeline/timeline.dart' as tl;
import '../../global.dart' as global;
import 'package:loris/partials/loadingbox.dart';
import 'package:loris/themes/themes.dart' as themes;
class Timeline extends StatefulWidget {
const Timeline({Key? key}) : super(key: key);
@ -104,97 +106,6 @@ class TimelineState extends State<Timeline> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
color: Theme.of(context).colorScheme.surface,
child: Wrap(
alignment: WrapAlignment.spaceEvenly,
children: [
IconButton(
onPressed: () {
reload();
},
icon: const Icon(Icons.refresh),
),
DropdownButtonHideUnderline(
child: DropdownButton(
alignment: Alignment.center,
borderRadius: BorderRadius.circular(8),
iconEnabledColor: Theme.of(context).colorScheme.onSurface,
value: selectedId,
items: identities,
onChanged: (dynamic value) async {
setState(() {
selectedId = value ?? global.settings!.activeIdentity;
global.settings!.saveActiveIdentity(selectedId);
reload();
});
},
),
),
DropdownButtonHideUnderline(
child: DropdownButton(
alignment: Alignment.center,
borderRadius: BorderRadius.circular(8),
iconEnabledColor: Theme.of(context).colorScheme.onSurface,
value: selectedTimelineType,
items: [
DropdownMenuItem(
alignment: Alignment.center,
value: tl.TimelineType.home,
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
text: "${"home-timeline".i18n()} ",
children: const [
WidgetSpan(
child: Icon(Icons.home),
),
],
),
),
),
DropdownMenuItem(
alignment: Alignment.center,
value: tl.TimelineType.local,
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
text: "${"local-timeline".i18n()} ",
children: const [
WidgetSpan(
child: Icon(Icons.people),
)
],
),
),
),
DropdownMenuItem(
alignment: Alignment.center,
value: tl.TimelineType.public,
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
text: "${"public-timeline".i18n()} ",
children: const [
WidgetSpan(
child: Icon(Icons.public),
),
],
),
),
),
],
onChanged: (tl.TimelineType? value) {
setState(() {
selectedTimelineType = value ?? tl.TimelineType.home;
reload();
});
},
),
)
],
),
),
Expanded(
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
@ -205,13 +116,123 @@ class TimelineState extends State<Timeline> {
separatorBuilder: (context, index) {
return const Divider(
color: Colors.transparent,
height: themes.defaultSeperatorHeight,
);
},
itemCount: children.length,
padding: const EdgeInsets.fromLTRB(24, 0, 24, 64),
addAutomaticKeepAlives: false,
),
),
Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
)),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: Wrap(
spacing: 18,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.spaceEvenly,
children: [
IconButton(
onPressed: () {
reload();
},
icon: const Icon(Icons.refresh),
),
DropdownButtonHideUnderline(
child: DropdownButton(
alignment: Alignment.center,
borderRadius: BorderRadius.circular(8),
iconEnabledColor: Theme.of(context).colorScheme.onSurface,
value: selectedId,
items: identities,
onChanged: (dynamic value) async {
setState(() {
selectedId = value ?? global.settings!.activeIdentity;
global.settings!.saveActiveIdentity(selectedId);
reload();
});
},
),
),
DropdownButtonHideUnderline(
child: DropdownButton(
alignment: Alignment.center,
borderRadius: BorderRadius.circular(8),
iconEnabledColor: Theme.of(context).colorScheme.onSurface,
value: selectedTimelineType,
items: [
DropdownMenuItem(
alignment: Alignment.center,
value: tl.TimelineType.home,
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
text: "${"home-timeline".i18n()} ",
children: const [
WidgetSpan(
child: Icon(Icons.home),
),
],
),
),
),
DropdownMenuItem(
alignment: Alignment.center,
value: tl.TimelineType.local,
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
text: "${"local-timeline".i18n()} ",
children: const [
WidgetSpan(
child: Icon(Icons.people),
)
],
),
),
),
DropdownMenuItem(
alignment: Alignment.center,
value: tl.TimelineType.public,
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
text: "${"public-timeline".i18n()} ",
children: const [
WidgetSpan(
child: Icon(Icons.public),
),
],
),
),
),
],
onChanged: (tl.TimelineType? value) {
setState(() {
selectedTimelineType = value ?? tl.TimelineType.home;
reload();
});
},
),
),
ElevatedButton.icon(
onPressed: (() => showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) => const MakePost(),
)),
icon: const Icon(Icons.create),
label: Text("write".i18n())),
],
),
),
),
],
);
}

View File

@ -135,7 +135,7 @@ class _InteractionButtonState extends State<InteractionButton> {
Widget build(BuildContext context) {
if (!widget.model.visibility.boostable &&
widget.type == interactions.InteractionType.reblog) {
return const Icon(Icons.lock);
return Icon(widget.model.visibility.icon);
}
if (success) {

View File

@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/dialogues/makepost.dart';
import 'package:loris/pages/chat/chat.dart';
import 'package:loris/pages/notifications/notifications.dart';
import 'package:loris/pages/search/search.dart';
import 'package:loris/pages/timeline/timeline.dart';
import 'package:loris/pages/settings/settings.dart';
import '../business_logic/websocket.dart' as websocket;
@ -29,6 +28,7 @@ class _MainScaffoldState extends State<MainScaffold> {
}
}
});
websocket.reloadWebsockets();
super.initState();
}
@ -39,58 +39,47 @@ class _MainScaffoldState extends State<MainScaffold> {
@override
Widget build(BuildContext context) {
websocket.reloadWebsockets();
final screens = [
const Timeline(),
chat(context),
const SearchPage(),
const Notifications(),
settings(context),
];
final buttons = [
FloatingActionButton(
child: const Icon(Icons.create),
onPressed: () => showDialog(
context: context,
builder: (context) => const MakePost(),
return SafeArea(
child: Scaffold(
extendBody: false,
body: IndexedStack(
index: index,
children: screens,
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: SizedBox(
height: 52,
child: BottomAppBar(
child: NavigationBar(
onDestinationSelected: (index) => setState(() {
this.index = index;
if (index == 2) {
unreadNotifs = 0;
}
}),
selectedIndex: index,
destinations: [
NavigationDestination(
icon: const Icon(Icons.forum), label: "timeline".i18n()),
NavigationDestination(
icon: const Icon(Icons.search), label: "search".i18n()),
NavigationDestination(
icon: Icon((unreadNotifs >= 1)
? Icons.notifications_active
: Icons.notifications),
label: "notifications".i18n()),
NavigationDestination(
icon: const Icon(Icons.settings),
label: "settings".i18n()),
]),
),
),
),
FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.person_add),
),
null,
null,
];
return Scaffold(
extendBody: true,
body: IndexedStack(
index: index,
children: screens,
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
floatingActionButton: buttons[index],
bottomNavigationBar: BottomAppBar(
child: NavigationBar(
onDestinationSelected: (index) => setState(() {
this.index = index;
if (index == 2) {
unreadNotifs = 0;
}
}),
selectedIndex: index,
destinations: [
NavigationDestination(
icon: const Icon(Icons.forum), label: "timeline".i18n()),
NavigationDestination(
icon: const Icon(Icons.chat), label: "chat".i18n()),
NavigationDestination(
icon: Icon((unreadNotifs >= 1)
? Icons.notifications_active
: Icons.notifications),
label: "notifications".i18n()),
NavigationDestination(
icon: const Icon(Icons.settings), label: "settings".i18n()),
]),
),
);
}

View File

@ -36,12 +36,14 @@ class ImageAttachmentDisplay extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Image.network(
model.url,
errorBuilder: ((context, error, stackTrace) => const Icon(Icons.error)),
width: double.infinity,
fit: BoxFit.fitWidth,
),
if (!model.url.endsWith("original"))
Image.network(
model.url,
errorBuilder: ((context, error, stackTrace) =>
const Icon(Icons.error)),
width: double.infinity,
fit: BoxFit.fitWidth,
),
const SizedBox(
height: 4,
),

46
lib/partials/name.dart Normal file
View File

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:loris/business_logic/emoji/emoji.dart';
class NameDisplay extends StatelessWidget {
const NameDisplay({
super.key,
required this.emoji,
required this.content,
required this.style,
});
final List<Emoji> emoji;
final String content;
final TextStyle style;
@override
Widget build(BuildContext context) {
String newtext = content;
newtext = newtext
.replaceAll("~", r"\~")
.replaceAll("*", r"\*")
.replaceAll("[", r"\[")
.replaceAll("]", r"\]")
.replaceAll("(", r"\(")
.replaceAll(")", r"\)");
for (var e in emoji) {
newtext = insertEmojiInMd(newtext, e);
}
return MarkdownBody(
data: newtext,
styleSheet: MarkdownStyleSheet(p: style),
imageBuilder: (uri, title, alt) {
try {
return Image.network(
uri.toString(),
height: style.fontSize,
errorBuilder: (context, error, stackTrace) =>
SelectableText(alt ?? error.toString()),
);
} catch (e) {
return SelectableText(alt ?? "");
}
});
}
}

View File

@ -1,14 +1,20 @@
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/account/account.dart';
import 'package:loris/business_logic/emoji/emoji.dart';
import 'package:loris/business_logic/timeline/media.dart';
import 'package:loris/dialogues/full_post_view.dart';
import 'package:loris/dialogues/makepost.dart';
import 'package:loris/dialogues/profile_view.dart';
import 'package:loris/partials/interaction_button.dart';
import 'package:loris/partials/media_attachment.dart';
import 'package:loris/partials/name.dart';
import 'package:loris/partials/post_options.dart';
import 'package:loris/partials/post_text_renderer.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../business_logic/timeline/timeline.dart' as tl;
import '../business_logic/interactions/interactions.dart' as interactions;
import 'package:loris/themes/themes.dart' as themes;
class Post extends StatefulWidget {
const Post({
@ -33,12 +39,17 @@ class _PostState extends State<Post> {
List<Widget> c = [];
c.addAll([
DisplayName(account: widget.model.account),
PostBody(
sensitive: widget.model.sensitive,
content: widget.model.content,
spoilerText: widget.model.spoilerText,
media: widget.model.attachments,
forceShow: !widget.hideSensitive,
const SizedBox(height: 2),
SizedBox(
width: double.maxFinite,
child: PostBody(
model: widget.model,
sensitive: widget.model.sensitive,
content: widget.model.content,
spoilerText: widget.model.spoilerText,
media: widget.model.attachments,
forceShow: !widget.hideSensitive,
),
),
]);
if (!widget.hideActionBar) {
@ -54,7 +65,7 @@ class _PostState extends State<Post> {
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: c,
);
}
@ -64,10 +75,12 @@ class DisplayName extends StatelessWidget {
const DisplayName({
required this.account,
this.isReblog = false,
this.openInBrowser = false,
Key? key,
}) : super(key: key);
final AccountModel account;
final bool isReblog;
final bool openInBrowser;
@override
Widget build(BuildContext context) {
@ -77,29 +90,47 @@ class DisplayName extends StatelessWidget {
} else {
usernameStyle = Theme.of(context).textTheme.displaySmall;
}
return Row(
children: [
ProfilePic(url: account.avatar),
const SizedBox(
width: 8,
),
Flexible(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
account.displayName,
style: usernameStyle,
),
Text(
account.acct,
style: Theme.of(context).textTheme.bodySmall,
),
],
String dname = account.displayName;
for (var emoji in account.emojis) {
dname = insertEmojiInMd(dname, emoji);
}
return InkWell(
borderRadius: const BorderRadius.all(themes.defaultRadius),
onTap: !openInBrowser
? () {
showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) => ProfileView(model: account),
);
}
: (() => launchUrlString(account.url)),
child: Row(
children: [
ProfilePic(url: account.avatar),
const SizedBox(
width: 8,
),
),
],
Flexible(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NameDisplay(
emoji: account.emojis,
content: account.displayName,
style: usernameStyle!,
),
SelectableText(
account.acct,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
);
}
}
@ -150,7 +181,7 @@ class ProfilePic extends StatelessWidget {
const double width = 64;
if (url.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: const BorderRadius.all(themes.defaultRadius),
child: Image.network(
fit: BoxFit.cover,
url,
@ -178,13 +209,17 @@ class PostBody extends StatefulWidget {
required this.content,
required this.media,
required this.forceShow,
this.openInBrowser = false,
Key? key,
required this.model,
}) : super(key: key);
final String content;
final String spoilerText;
final bool sensitive;
final bool forceShow;
final List<MediaAttachmentModel> media;
final bool openInBrowser;
final tl.PostModel model;
@override
State<PostBody> createState() => _PostBodyState();
@ -203,62 +238,77 @@ class _PostBodyState extends State<PostBody> {
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(0, 6, 0, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Visibility(
visible: widget.forceShow ? false : widget.sensitive,
child: Column(
children: [
SelectableText.rich(
TextSpan(
children: [
WidgetSpan(
child: OutlinedButton.icon(
onPressed: () {
setState(() {
visible = !visible;
if (visible) {
cwButtonIcon = const Icon(Icons.visibility_off);
cwButtonText = "hide".i18n();
} else {
cwButtonText = "show".i18n();
cwButtonIcon = const Icon(Icons.visibility);
}
});
},
icon: cwButtonIcon,
label: Text(cwButtonText),
),
return InkWell(
borderRadius: const BorderRadius.all(themes.defaultRadius),
onTap: () => showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) => FullPostView(originPostModel: widget.model),
),
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Visibility(
visible: widget.forceShow ? false : widget.sensitive,
child: Column(
children: [
SizedBox(
width: double.maxFinite,
child: SelectableText.rich(
TextSpan(
children: [
WidgetSpan(
child: ElevatedButton.icon(
onPressed: () {
setState(() {
visible = !visible;
if (visible) {
cwButtonIcon =
const Icon(Icons.visibility_off);
cwButtonText = "hide".i18n();
} else {
cwButtonText = "show".i18n();
cwButtonIcon = const Icon(Icons.visibility);
}
});
},
icon: cwButtonIcon,
label: Text(cwButtonText),
),
),
const WidgetSpan(
child: SizedBox(
width: 8,
)),
TextSpan(text: widget.spoilerText),
],
),
const WidgetSpan(
child: SizedBox(
width: 8,
)),
TextSpan(text: widget.spoilerText),
],
),
),
),
const SizedBox(
height: 8,
)
],
const SizedBox(
height: 8,
)
],
),
),
),
Visibility(
visible: visible || widget.forceShow,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PostTextRenderer(input: widget.content),
MediaAttachments(models: widget.media),
],
Visibility(
visible: visible || widget.forceShow,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PostTextRenderer(
input: widget.content,
identityName: widget.model.identity,
emoji: widget.model.emojis,
),
MediaAttachments(models: widget.media),
],
),
),
),
],
],
),
),
);
}
@ -275,14 +325,19 @@ class PostActionBar extends StatefulWidget {
class _PostActionBarState extends State<PostActionBar> {
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
return Wrap(
spacing: 24,
runSpacing: 24,
runAlignment: WrapAlignment.center,
alignment: WrapAlignment.spaceAround,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Expanded(
flex: 20,
SizedBox(
width: 64,
child: IconButton(
onPressed: () {
showDialog(
barrierColor: Colors.transparent,
context: context,
builder: ((context) => MakePost(
inReplyTo: widget.model,
@ -292,30 +347,42 @@ class _PostActionBarState extends State<PostActionBar> {
tooltip: "reply".i18n(),
),
),
Expanded(
flex: 20,
SizedBox(
width: 64,
child: InteractionButton(
model: widget.model,
type: interactions.InteractionType.reblog,
),
),
Expanded(
flex: 20,
SizedBox(
width: 64,
child: InteractionButton(
model: widget.model,
type: interactions.InteractionType.favorite,
),
),
Expanded(
flex: 20,
SizedBox(
width: 64,
child: IconButton(
tooltip: "post-options".i18n(),
onPressed: () {
popupPostOptions(context, widget.model);
},
icon: const Icon(Icons.more_horiz),
),
)
tooltip: "show-in-full".i18n(),
onPressed: () {
showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) => FullPostView(
originPostModel: widget.model,
),
);
},
icon: const Icon(Icons.open_in_full)),
),
IconButton(
tooltip: "post-options".i18n(),
onPressed: () {
popupPostOptions(context, widget.model);
},
icon: const Icon(Icons.more_horiz),
),
],
);
}

View File

@ -2,12 +2,15 @@ import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/interactions/interactions.dart';
import 'package:loris/business_logic/timeline/timeline.dart';
import 'package:loris/dialogues/full_post_view.dart';
import 'package:loris/partials/interaction_button.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:clipboard/clipboard.dart';
import 'package:loris/global.dart' as global;
void popupPostOptions(context, PostModel model) {
showModalBottomSheet(
barrierColor: Colors.transparent,
context: context,
builder: (context) => PostOptions(model: model),
);
@ -26,17 +29,17 @@ class _PostOptionsState extends State<PostOptions> {
@override
Widget build(BuildContext context) {
final time = DateTime.parse(widget.model.createdAt).toLocal();
List<Widget?> c = [
const SizedBox(
height: 24,
),
// title
SelectableText("post-options".i18n(),
style: Theme.of(context).textTheme.displayMedium),
// time
SelectableText(
widget.model.createdAt
.replaceAll("T", " ")
.replaceAll("-", ".")
.substring(0, 19),
"${"day-${time.weekday}".i18n()} ${time.day}.${time.month}.${time.year} ${time.hour}:${time.minute} ${time.timeZoneName.toLowerCase()}",
style: Theme.of(context).textTheme.bodyMedium,
),
Row(
@ -49,6 +52,17 @@ class _PostOptionsState extends State<PostOptions> {
const SizedBox(
height: 24,
),
TextButton.icon(
onPressed: () => showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) =>
FullPostView(originPostModel: widget.model),
),
icon: const Icon(
Icons.open_in_full,
),
label: Text("show-in-full".i18n())),
TextButton.icon(
onPressed: () async {
FlutterClipboard.copy(widget.model.uri);
@ -80,6 +94,7 @@ class _PostOptionsState extends State<PostOptions> {
"show-in-browser".i18n(),
),
),
widget.model.visibility.boostable
? InteractionButton(
model: widget.model,
@ -92,6 +107,24 @@ class _PostOptionsState extends State<PostOptions> {
type: InteractionType.favorite,
extended: true,
),
if ("${widget.model.account.acct}@${global.settings!.identities[widget.model.identity]!.instanceUrl}" ==
widget.model.identity)
TextButton.icon(
onPressed: () async {
final result = await deletePost(widget.model);
if (result == 200) {
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
// ignore: use_build_context_synchronously
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("post-deleted".i18n())));
}
// ignore: use_build_context_synchronously
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("deletion-failed".i18n())));
},
icon: const Icon(Icons.delete),
label: Text("delete-this".i18n())),
const SizedBox(
height: 24,
),

View File

@ -1,31 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:html2md/html2md.dart' as html2md;
import 'package:loris/business_logic/emoji/emoji.dart';
import 'package:loris/dialogues/profile_view.dart';
import 'package:url_launcher/url_launcher.dart';
import '../business_logic/account/account.dart' as account;
class PostTextRenderer extends StatelessWidget {
const PostTextRenderer({
required this.identityName,
required this.input,
this.emoji = const [],
Key? key,
}) : super(key: key);
final String input;
final String identityName;
final List<Emoji> emoji;
@override
Widget build(BuildContext context) {
final MarkdownStyleSheet mdStyle = MarkdownStyleSheet(
codeblockDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
style: BorderStyle.solid,
),
),
code: TextStyle(
decorationColor: Theme.of(context).backgroundColor,
color: Theme.of(context).colorScheme.onBackground,
backgroundColor: Colors.transparent,
),
a: TextStyle(color: Theme.of(context).colorScheme.secondary),
blockquote: TextStyle(color: Theme.of(context).colorScheme.onBackground),
blockquoteDecoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
style: BorderStyle.solid,
)),
color: Theme.of(context).colorScheme.background,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
style: BorderStyle.solid,
),
),
);
String s = html2md.convert(input);
for (var e in emoji) {
s = insertEmojiInMd(s, e);
}
return MarkdownBody(
onTapLink: ((text, href, title) {
imageBuilder: (uri, title, alt) {
return Image.network(
"${uri.scheme}://${uri.host}${uri.path}",
height: Theme.of(context).textTheme.bodyMedium?.fontSize,
);
},
onTapLink: ((text, href, title) async {
if (href != null) {
// see if this is an account and in that case search for it
if (text.startsWith("@")) {
final result = await account.searchModel(identityName, href);
if (result.keys.first == 200) {
showDialog(
context: context,
builder: (context) => ProfileView(model: result.values.first!),
);
return;
}
}
// if this is not an account or the account couldn't be found
// then just open it in the default browser
launchUrl(Uri.parse(href));
}
}),

View File

@ -3,10 +3,16 @@ import 'package:localization/localization.dart';
import 'package:loris/partials/post.dart';
import '../business_logic/timeline/timeline.dart' as logic;
import '../global.dart' as global;
import 'package:loris/themes/themes.dart' as themes;
class Thread extends StatefulWidget {
const Thread({required this.model, Key? key}) : super(key: key);
const Thread({
required this.model,
Key? key,
this.constrained = true,
}) : super(key: key);
final logic.ThreadModel model;
final bool constrained;
@override
State<Thread> createState() => _ThreadState();
@ -16,14 +22,30 @@ class _ThreadState extends State<Thread> {
Set<String> contentWarnings = {};
int sensitivePosts = 0;
bool showSensitive = false;
bool collapsed = false;
@override
Widget build(BuildContext context) {
List<Widget> c = [];
List<Widget> c = [
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () {
setState(() {
collapsed = !collapsed;
});
},
icon: Icon(collapsed ? Icons.fullscreen : Icons.fullscreen_exit),
label: Text(collapsed ? "expand".i18n() : "collapse".i18n())),
)
];
for (var element in widget.model.posts) {
c.add(Post(
model: element,
hideSensitive: !showSensitive,
c.add(Visibility(
visible: !collapsed,
child: Post(
model: element,
hideSensitive: !showSensitive,
),
));
if (element.sensitive) {
sensitivePosts += 1;
@ -31,11 +53,16 @@ class _ThreadState extends State<Thread> {
}
}
contentWarnings.map(
(e) => e.trim(),
);
contentWarnings.removeWhere((element) => element == "");
if (sensitivePosts > 1 && c.length > 1) {
String s = "";
int i = 0;
for (var element in contentWarnings) {
if (i == 0) {
if (i == 0 && i < contentWarnings.length - 1) {
s = "$element;";
} else if (i < contentWarnings.length - 1) {
s = "$s $element;";
@ -44,25 +71,44 @@ class _ThreadState extends State<Thread> {
}
i++;
}
c.insert(
0,
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: SelectableText(s),
),
OutlinedButton.icon(
onPressed: () {
setState(() {
showSensitive = !showSensitive;
});
},
icon:
Icon(showSensitive ? Icons.visibility_off : Icons.visibility),
label: Text(showSensitive ? "hide".i18n() : "show".i18n()),
),
],
Visibility(
visible: !collapsed,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(child: SelectableText(s)),
ElevatedButton.icon(
onPressed: () {
setState(() {
showSensitive = !showSensitive;
});
},
icon: Icon(
showSensitive ? Icons.visibility_off : Icons.visibility),
label: Text(showSensitive ? "hide".i18n() : "show".i18n()),
),
],
),
),
);
}
if (!widget.constrained) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: c,
),
);
}
@ -70,25 +116,25 @@ class _ThreadState extends State<Thread> {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(4),
child: Container(
padding: const EdgeInsets.all(24),
width: (MediaQuery.of(context).size.width *
global.settings!.postWidth) -
56,
constraints: BoxConstraints(
maxWidth: global.settings!.maxPostWidth,
minWidth: 375,
Container(
clipBehavior: Clip.none,
foregroundDecoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
width: 2,
color: Theme.of(context).colorScheme.secondary,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border:
Border.all(color: Theme.of(context).colorScheme.secondary),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: c,
),
width: global.getWidth(context),
constraints: global.getConstraints(context),
child: Material(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Padding(
padding: themes.defaultInsideMargins,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: c,
),
),
),
),

View File

@ -2,35 +2,39 @@ import 'package:flutter/material.dart';
import 'themes.dart' as themes;
themes.CustomColors themeDark = themes.CustomColors(
"adwaita dark",
const Color.fromARGB(255, 26, 95, 180),
const ColorScheme(
brightness: Brightness.dark,
primary: Color.fromARGB(255, 255, 190, 111),
onPrimary: Color.fromARGB(255, 61, 56, 70),
secondary: Color.fromARGB(255, 143, 240, 164),
onSecondary: Color.fromARGB(255, 61, 56, 70),
error: Color.fromARGB(255, 255, 85, 85),
onError: Color.fromARGB(255, 61, 56, 70),
background: Color.fromARGB(255, 36, 31, 49),
onBackground: Color.fromARGB(255, 246, 245, 244),
surface: Color.fromARGB(255, 61, 56, 70),
onSurface: Color.fromARGB(255, 246, 245, 244),
));
"adwaita dark",
const Color.fromARGB(255, 26, 95, 180),
const ColorScheme(
brightness: Brightness.dark,
primary: Color.fromARGB(255, 255, 190, 111),
onPrimary: Color.fromARGB(255, 61, 56, 70),
secondary: Color.fromARGB(255, 143, 240, 164),
onSecondary: Color.fromARGB(255, 61, 56, 70),
error: Color.fromARGB(255, 255, 85, 85),
onError: Color.fromARGB(255, 61, 56, 70),
background: Color.fromARGB(255, 36, 31, 49),
onBackground: Color.fromARGB(255, 246, 245, 244),
surface: Color.fromARGB(255, 61, 56, 70),
onSurface: Color.fromARGB(255, 246, 245, 244),
),
const Color.fromARGB(255, 36, 31, 49),
);
themes.CustomColors themeLight = themes.CustomColors(
"adwaita light",
const Color.fromARGB(255, 153, 193, 241),
const ColorScheme(
brightness: Brightness.dark,
primary: Color.fromARGB(255, 198, 70, 0),
onPrimary: Color.fromARGB(255, 246, 245, 244),
secondary: Color.fromARGB(255, 38, 162, 105),
onSecondary: Color.fromARGB(255, 246, 245, 244),
error: Color.fromARGB(255, 165, 29, 45),
onError: Color.fromARGB(255, 246, 245, 244),
background: Color.fromARGB(255, 222, 221, 218),
onBackground: Color.fromARGB(255, 36, 31, 49),
surface: Color.fromARGB(255, 246, 245, 244),
onSurface: Color.fromARGB(255, 36, 31, 49),
));
"adwaita light",
const Color.fromARGB(255, 153, 193, 241),
const ColorScheme(
brightness: Brightness.dark,
primary: Color.fromARGB(255, 198, 70, 0),
onPrimary: Color.fromARGB(255, 246, 245, 244),
secondary: Color.fromARGB(255, 38, 162, 105),
onSecondary: Color.fromARGB(255, 246, 245, 244),
error: Color.fromARGB(255, 165, 29, 45),
onError: Color.fromARGB(255, 246, 245, 244),
background: Color.fromARGB(255, 222, 221, 218),
onBackground: Color.fromARGB(255, 36, 31, 49),
surface: Color.fromARGB(255, 246, 245, 244),
onSurface: Color.fromARGB(255, 36, 31, 49),
),
const Color.fromARGB(255, 222, 221, 218),
);

View File

@ -21,4 +21,5 @@ themes.CustomColors theme = themes.CustomColors(
onBackground: Color.fromARGB(255, 248, 248, 242),
surface: Color.fromARGB(255, 40, 42, 54),
onSurface: Color.fromARGB(255, 248, 248, 242),
));
),
const Color.fromARGB(255, 68, 71, 90));

21
lib/themes/first.dart Normal file
View File

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'themes.dart' as themes;
themes.CustomColors theme = themes.CustomColors(
"website #1",
const Color.fromARGB(255, 89, 89, 89),
const ColorScheme(
brightness: Brightness.light,
primary: Color.fromARGB(255, 0, 25, 53),
onPrimary: Color.fromARGB(255, 255, 255, 255),
secondary: Color.fromARGB(255, 0, 184, 255),
onSecondary: Color.fromARGB(255, 255, 255, 255),
error: Color.fromARGB(255, 245, 248, 250),
onError: Color.fromARGB(255, 20, 23, 26),
background: Color.fromARGB(255, 0, 25, 53),
onBackground: Color.fromARGB(255, 255, 255, 255),
surface: Color.fromARGB(255, 255, 255, 255),
onSurface: Color.fromARGB(255, 0, 0, 0),
),
const Color.fromARGB(255, 188, 195, 202),
);

View File

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'themes.dart' as themes;
themes.CustomColors theme = themes.CustomColors(
"website #4",
const Color.fromARGB(
255,
255,
170,
90,
),
const ColorScheme(
brightness: Brightness.light,
primary: Color.fromARGB(255, 131, 37, 79),
onPrimary: Color.fromARGB(255, 255, 249, 242),
secondary: Color.fromARGB(255, 81, 17, 46),
onSecondary: Color.fromARGB(255, 255, 249, 242),
error: Color.fromARGB(255, 255, 85, 85),
onError: Color.fromARGB(255, 255, 249, 242),
background: Color.fromARGB(255, 255, 241, 223),
onBackground: Color.fromARGB(255, 25, 25, 25),
surface: Color.fromARGB(255, 255, 249, 242),
onSurface: Color.fromARGB(255, 25, 25, 25),
),
const Color.fromARGB(255, 255, 241, 223),
);

View File

@ -2,35 +2,39 @@ import 'package:flutter/material.dart';
import 'themes.dart' as themes;
themes.CustomColors themeDark = themes.CustomColors(
"gruvbox dark",
const Color.fromARGB(255, 175, 58, 3),
const ColorScheme(
brightness: Brightness.dark,
primary: Color.fromARGB(255, 211, 134, 155),
onPrimary: Color.fromARGB(255, 29, 32, 33),
secondary: Color.fromARGB(255, 184, 187, 38),
onSecondary: Color.fromARGB(255, 29, 32, 33),
error: Color.fromARGB(255, 251, 73, 52),
onError: Color.fromARGB(255, 29, 32, 33),
background: Color.fromARGB(255, 29, 32, 33),
onBackground: Color.fromARGB(255, 249, 245, 215),
surface: Color.fromARGB(255, 50, 48, 47),
onSurface: Color.fromARGB(255, 249, 245, 215),
));
"gruvbox dark",
const Color.fromARGB(255, 175, 58, 3),
const ColorScheme(
brightness: Brightness.dark,
primary: Color.fromARGB(255, 211, 134, 155),
onPrimary: Color.fromARGB(255, 29, 32, 33),
secondary: Color.fromARGB(255, 184, 187, 38),
onSecondary: Color.fromARGB(255, 29, 32, 33),
error: Color.fromARGB(255, 251, 73, 52),
onError: Color.fromARGB(255, 29, 32, 33),
background: Color.fromARGB(255, 29, 32, 33),
onBackground: Color.fromARGB(255, 249, 245, 215),
surface: Color.fromARGB(255, 50, 48, 47),
onSurface: Color.fromARGB(255, 249, 245, 215),
),
const Color.fromARGB(255, 29, 32, 33),
);
themes.CustomColors themeLight = themes.CustomColors(
"gruvbox light",
const Color.fromARGB(255, 255, 190, 111),
const ColorScheme(
brightness: Brightness.light,
primary: Color.fromARGB(255, 175, 58, 3),
onPrimary: Color.fromARGB(255, 249, 245, 215),
secondary: Color.fromARGB(255, 121, 116, 14),
onSecondary: Color.fromARGB(255, 249, 245, 215),
error: Color.fromARGB(255, 255, 85, 85),
onError: Color.fromARGB(255, 249, 245, 215),
background: Color.fromARGB(255, 242, 229, 188),
onBackground: Color.fromARGB(255, 29, 32, 33),
surface: Color.fromARGB(255, 249, 245, 215),
onSurface: Color.fromARGB(255, 29, 32, 33),
));
"gruvbox light",
const Color.fromARGB(255, 255, 190, 111),
const ColorScheme(
brightness: Brightness.light,
primary: Color.fromARGB(255, 175, 58, 3),
onPrimary: Color.fromARGB(255, 249, 245, 215),
secondary: Color.fromARGB(255, 121, 116, 14),
onSecondary: Color.fromARGB(255, 249, 245, 215),
error: Color.fromARGB(255, 255, 85, 85),
onError: Color.fromARGB(255, 249, 245, 215),
background: Color.fromARGB(255, 242, 229, 188),
onBackground: Color.fromARGB(255, 29, 32, 33),
surface: Color.fromARGB(255, 249, 245, 215),
onSurface: Color.fromARGB(255, 29, 32, 33),
),
const Color.fromARGB(255, 242, 229, 188),
);

21
lib/themes/second.dart Normal file
View File

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'themes.dart' as themes;
themes.CustomColors theme = themes.CustomColors(
"website #2",
const Color.fromARGB(255, 170, 184, 194),
const ColorScheme(
brightness: Brightness.light,
primary: Color.fromARGB(255, 29, 161, 242),
onPrimary: Color.fromARGB(255, 245, 248, 250),
secondary: Color.fromARGB(255, 29, 161, 242),
onSecondary: Color.fromARGB(255, 245, 248, 250),
error: Color.fromARGB(255, 245, 248, 250),
onError: Color.fromARGB(255, 20, 23, 26),
background: Color.fromARGB(255, 245, 248, 250),
onBackground: Color.fromARGB(255, 20, 23, 26),
surface: Color.fromARGB(255, 245, 248, 250),
onSurface: Color.fromARGB(255, 20, 23, 26),
),
const Color.fromARGB(255, 225, 232, 237),
);

View File

@ -2,23 +2,25 @@ import 'package:flutter/material.dart';
import 'themes.dart' as themes;
themes.CustomColors theme = themes.CustomColors(
"tess 🐯",
const Color.fromARGB(
255,
13,
69,
87,
),
const ColorScheme(
brightness: Brightness.dark,
primary: Color.fromARGB(255, 230, 62, 98),
onPrimary: Color.fromARGB(255, 3, 16, 20),
secondary: Color.fromARGB(255, 230, 62, 98),
onSecondary: Color.fromARGB(255, 3, 16, 20),
error: Color.fromARGB(255, 241, 108, 86),
onError: Color.fromARGB(255, 3, 16, 20),
background: Color.fromARGB(255, 3, 16, 20),
onBackground: Color.fromARGB(255, 238, 141, 239),
surface: Color.fromARGB(255, 3, 16, 20),
onSurface: Color.fromARGB(255, 238, 141, 239),
));
"tess 🐯",
const Color.fromARGB(
255,
6,
34,
42,
),
const ColorScheme(
brightness: Brightness.dark,
primary: Color.fromARGB(255, 230, 62, 98),
onPrimary: Color.fromARGB(255, 3, 16, 20),
secondary: Color.fromARGB(255, 230, 62, 98),
onSecondary: Color.fromARGB(255, 3, 16, 20),
error: Color.fromARGB(255, 241, 108, 86),
onError: Color.fromARGB(255, 3, 16, 20),
background: Color.fromARGB(255, 3, 16, 20),
onBackground: Color.fromARGB(255, 238, 141, 239),
surface: Color.fromARGB(255, 3, 16, 20),
onSurface: Color.fromARGB(255, 238, 141, 239),
),
const Color.fromARGB(255, 3, 16, 20),
);

View File

@ -3,6 +3,22 @@ import 'dracula.dart' as color_dracula;
import 'tess.dart' as color_tess;
import 'adwaita.dart' as color_adwaita;
import 'gruvbox.dart' as color_gruvbox;
import 'fourth_website.dart' as color_fourth;
import 'second.dart' as color_second;
import 'first.dart' as color_first;
bool checkActive(Set<MaterialState> states) {
return states.intersection({
MaterialState.focused,
MaterialState.hovered,
MaterialState.pressed
}).isNotEmpty;
}
const defaultRadius = Radius.circular(8);
const defaultSeperatorHeight = 4.0;
const defaultInsideMargins = EdgeInsets.all(18);
const defaultMargins = EdgeInsets.fromLTRB(18, 8, 18, 8);
// color schemes to pick from can be added here
// there is a class to create these
@ -13,14 +29,19 @@ final available = [
color_tess.theme,
color_gruvbox.themeDark,
color_gruvbox.themeLight,
color_first.theme,
color_second.theme,
color_fourth.theme,
];
ThemeData getTheme(CustomColors colors) {
return ThemeData(
floatingActionButtonTheme: const FloatingActionButtonThemeData(
applyElevationOverlayColor: false,
floatingActionButtonTheme: FloatingActionButtonThemeData(
hoverColor: colors.colorScheme.onSurface,
elevation: 0,
enableFeedback: false,
hoverElevation: 24,
hoverElevation: 0,
),
fontFamily: 'Atkinson',
textTheme: TextTheme(
@ -68,6 +89,41 @@ ThemeData getTheme(CustomColors colors) {
size: 24,
color: colors.colorScheme.onSurface,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
shadowColor: MaterialStateProperty.all(Colors.transparent),
shape: MaterialStateProperty.resolveWith((states) {
if (checkActive(states)) {
return RoundedRectangleBorder(
borderRadius: const BorderRadius.all(defaultRadius),
side: BorderSide(color: colors.colorScheme.primary, width: 2),
);
}
return const RoundedRectangleBorder(
borderRadius: BorderRadius.all(defaultRadius));
}),
elevation: MaterialStateProperty.all(0),
foregroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.disabled)) {
return colors.colorScheme.surface;
}
if (checkActive(states)) return colors.colorScheme.primary;
return null;
}),
backgroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.disabled)) return colors.hintColor;
if (checkActive(states)) return colors.colorScheme.onPrimary;
return null;
}),
overlayColor: MaterialStateProperty.all(Colors.transparent),
textStyle: MaterialStateProperty.resolveWith((states) =>
const TextStyle(
fontSize: 18,
fontFamily: "atkinson",
fontWeight: FontWeight.w700)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
@ -86,6 +142,18 @@ ThemeData getTheme(CustomColors colors) {
),
textButtonTheme: TextButtonThemeData(
style: ButtonStyle(
elevation: MaterialStateProperty.all(0),
shape: MaterialStateProperty.all(const RoundedRectangleBorder(
borderRadius: BorderRadius.all(defaultRadius))),
shadowColor: MaterialStateProperty.all(Colors.transparent),
backgroundColor: MaterialStateProperty.resolveWith((states) {
if (checkActive(states)) return colors.colorScheme.primary;
return null;
}),
foregroundColor: MaterialStateProperty.resolveWith((states) {
if (checkActive(states)) return colors.colorScheme.onPrimary;
return null;
}),
textStyle: MaterialStateProperty.all(
const TextStyle(fontSize: 18),
),
@ -103,11 +171,13 @@ ThemeData getTheme(CustomColors colors) {
colorScheme: colors.colorScheme,
errorColor: colors.colorScheme.error,
bottomAppBarTheme: BottomAppBarTheme(
color: colors.colorScheme.surface,
shape: const CircularNotchedRectangle(),
elevation: 0,
color: colors.colorScheme.surface,
),
navigationBarTheme: NavigationBarThemeData(
indicatorShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(defaultRadius),
),
labelTextStyle: MaterialStateProperty.all(
TextStyle(
color: colors.colorScheme.onSurface,
@ -115,10 +185,10 @@ ThemeData getTheme(CustomColors colors) {
),
),
backgroundColor: Colors.transparent,
labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected,
indicatorColor: Colors.transparent,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
indicatorColor: colors.hoverColor,
elevation: 0,
height: 64,
height: 52,
),
scrollbarTheme: ScrollbarThemeData(
thumbColor: MaterialStateProperty.all(colors.hintColor),
@ -138,24 +208,33 @@ ThemeData getTheme(CustomColors colors) {
fontWeight: FontWeight.w700,
),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
borderRadius: const BorderRadius.all(defaultRadius),
color: colors.colorScheme.primary,
),
),
canvasColor: colors.colorScheme.surface,
dialogBackgroundColor: colors.colorScheme.surface,
dialogTheme: DialogTheme(
elevation: 0,
backgroundColor: colors.colorScheme.surface,
shape: RoundedRectangleBorder(
side: BorderSide(
width: 2,
color: colors.colorScheme.secondary,
style: BorderStyle.solid),
borderRadius: const BorderRadius.all(defaultRadius))),
selectedRowColor: colors.colorScheme.background,
textSelectionTheme:
TextSelectionThemeData(selectionColor: colors.hintColor),
primaryIconTheme: const IconThemeData(size: 24),
hoverColor: colors.colorScheme.background,
shadowColor: colors.colorScheme.surface,
focusColor: colors.hintColor,
hoverColor: colors.hoverColor,
shadowColor: Colors.transparent,
focusColor: colors.hoverColor,
indicatorColor: colors.hintColor,
disabledColor: colors.hintColor,
unselectedWidgetColor: colors.hintColor,
toggleableActiveColor: colors.colorScheme.primary,
splashColor: colors.colorScheme.onSurface,
splashFactory: NoSplash.splashFactory,
highlightColor: colors.hintColor,
inputDecorationTheme: InputDecorationTheme(
helperStyle: TextStyle(
@ -172,12 +251,20 @@ ThemeData getTheme(CustomColors colors) {
),
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: colors.colorScheme.primary,
refreshBackgroundColor: colors.hintColor,
linearTrackColor: colors.hintColor,
circularTrackColor: colors.hintColor,
),
);
}
class CustomColors {
late String name;
late Color hintColor;
// must be set for twilight themes
late Color hoverColor;
late ColorScheme colorScheme;
CustomColors(this.name, this.hintColor, this.colorScheme);
CustomColors(this.name, this.hintColor, this.colorScheme, this.hoverColor);
}

View File

@ -14,7 +14,7 @@ packages:
name: async
url: "https://pub.dartlang.org"
source: hosted
version: "2.8.2"
version: "2.9.0"
boolean_selector:
dependency: transitive
description:
@ -28,7 +28,7 @@ packages:
name: characters
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
version: "1.2.1"
charcode:
dependency: transitive
description:
@ -49,7 +49,7 @@ packages:
name: clock
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
version: "1.1.1"
collection:
dependency: transitive
description:
@ -84,7 +84,7 @@ packages:
name: fake_async
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
version: "1.3.1"
ffi:
dependency: transitive
description:
@ -99,6 +99,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.4"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.0"
flutter:
dependency: "direct main"
description: flutter
@ -122,7 +129,14 @@ packages:
name: flutter_markdown
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.10+5"
version: "0.6.10+6"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
flutter_test:
dependency: "direct dev"
description: flutter
@ -202,28 +216,35 @@ packages:
name: matcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.11"
version: "0.12.12"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.4"
version: "0.1.5"
meta:
dependency: transitive
description:
name: meta
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.0"
version: "1.8.0"
mime:
dependency: "direct main"
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
path:
dependency: transitive
description:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.1"
version: "1.8.2"
path_provider_linux:
dependency: transitive
description:
@ -244,7 +265,7 @@ packages:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.3"
platform:
dependency: transitive
description:
@ -258,7 +279,7 @@ packages:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.3"
process:
dependency: transitive
description:
@ -279,7 +300,7 @@ packages:
name: shared_preferences_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.12"
version: "2.0.13"
shared_preferences_ios:
dependency: transitive
description:
@ -307,7 +328,7 @@ packages:
name: shared_preferences_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.1.0"
shared_preferences_web:
dependency: transitive
description:
@ -328,7 +349,7 @@ packages:
name: shelf
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.2"
version: "1.4.0"
sky_engine:
dependency: transitive
description: flutter
@ -340,7 +361,7 @@ packages:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.2"
version: "1.9.0"
stack_trace:
dependency: transitive
description:
@ -361,21 +382,21 @@ packages:
name: string_scanner
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
version: "1.1.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.9"
version: "0.4.12"
typed_data:
dependency: transitive
description:
@ -383,6 +404,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
universal_html:
dependency: "direct main"
description:
name: universal_html
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
universal_io:
dependency: "direct main"
description:
name: universal_io
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
url_launcher:
dependency: "direct main"
description:
@ -396,7 +431,7 @@ packages:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.17"
version: "6.0.19"
url_launcher_ios:
dependency: transitive
description:
@ -439,6 +474,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
url_strategy:
dependency: "direct main"
description:
name: url_strategy
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
vector_math:
dependency: transitive
description:
@ -459,7 +501,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.7.0"
version: "3.0.0"
xdg_directories:
dependency: transitive
description:

View File

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.1.0+1
version: 0.5.0+1
environment:
sdk: ">=2.17.3 <3.0.0"
@ -48,6 +48,11 @@ dependencies:
flutter_markdown: ^0.6.10+5
markdown: ^5.0.0
html2md: ^1.2.6
url_strategy: ^0.2.0
universal_html: ^2.0.8
universal_io: ^2.0.4
file_picker: ^5.2.0
mime: ^1.0.2
dev_dependencies:
flutter_test:

18
web/login/redirect.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Connexion Succeeded</title>
<meta name="description"
content="Simple, quick, standalone responsive placeholder without any additional resources">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
</body>
<script>
window.opener.postMessage(window.location.href, '*');
</script>
</html>