Compare commits

...

62 Commits
0.3 ... 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
49 changed files with 2486 additions and 611 deletions

View File

@ -10,7 +10,8 @@ the best (soon)(i promise) fedi client for gotosocial and mastodon
- [x] making text posts and replies
- [x] liking and boosting posts
- [x] content warnings
- [ ] viewing profiles
- [ ] viewing posts with full context
- [ ] search
- [ ] posting media attachments
- [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

@ -2,6 +2,7 @@ import 'dart:convert';
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;
@ -19,6 +20,7 @@ class AccountModel {
final bool? bot;
final bool? suspended;
final String identity;
final List<Emoji> emojis;
AccountModel({
required this.identity,
@ -34,9 +36,17 @@ class AccountModel {
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"],
@ -51,6 +61,7 @@ class AccountModel {
bot: json["bot"],
fields: json["fields"],
suspended: json["suspended"],
emojis: emoji,
);
}
}
@ -122,14 +133,14 @@ Future<Map<int, RelationshipModel?>> getRelationship(
);
final response = await http.get(uri, headers: headers);
if (response.statusCode == 200) {
if (response.statusCode == 200 && jsonDecode(response.body).isNotEmpty) {
return {
response.statusCode:
RelationshipModel.fromJson(jsonDecode(response.body)[0], identityName)
};
}
return {404: null};
return {response.statusCode: null};
}
Future<Map<int, AccountModel?>> searchModel(

View File

@ -208,7 +208,7 @@ void openBrowserForAuthCode(String baseurl, App app) {
"&redirect_uri=${kIsWeb ? "${Uri.base.origin}/login/redirect.html" : "http://localhost:1312"}" +
"&response_type=code");
launchUrl(
mode: LaunchMode.inAppWebView,
mode: LaunchMode.externalApplication,
url,
webOnlyWindowName: "loris",
webViewConfiguration: const WebViewConfiguration(),

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

@ -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

@ -127,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;
@ -97,8 +101,10 @@ class PostModel implements Comparable {
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,6 +116,7 @@ class PostModel implements Comparable {
spoilerText = json["spoiler_text"] as String;
favourited = json["favourited"] as bool;
reblogged = json["reblogged"] as bool;
inReplyTo = json["in_reply_to_id"];
account = AccountModel.fromJson(json["account"], identity);
attachments = [];
List<dynamic> jsonAttachmentList = json["media_attachments"];
@ -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

@ -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!;
});
}
}
@ -100,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(
@ -140,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,
@ -156,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,
@ -173,27 +237,74 @@ class _MakePostState extends State<MakePost> {
"send-post".i18n(),
),
// send the post!!!
onPressed: () async {
final model = MakePostModel(
spoilerText: spoilerText.trim(),
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,
@ -204,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,
@ -221,6 +334,10 @@ class _MakePostState extends State<MakePost> {
const SizedBox(
height: 24,
),
]);
// these are the action buttons
c.add(
Wrap(
runSpacing: 8,
spacing: 24,
@ -229,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

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
@ -10,6 +11,7 @@ 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({
@ -46,7 +48,7 @@ class _ProfileViewState extends State<ProfileView> {
}
}
void addIdentity(String identityName, int i) async {
Future<void> addIdentity(String identityName, int i) async {
final m = await searchModel(identityName, widget.model.url);
if (m.values.first != null) {
if (mounted) {
@ -56,7 +58,7 @@ class _ProfileViewState extends State<ProfileView> {
}
await addRelationship(m.values.first!);
}
if (i >= global.settings!.identities.length - 1) {
if (i >= global.settings!.identities.length - 2) {
if (mounted) {
setState(() {
loading = false;
@ -101,12 +103,10 @@ class _ProfileViewState extends State<ProfileView> {
global.settings!.identities.entries,
(element) async {
if (element.key == widget.model.identity) {
i++;
addIdentity(element.key, i);
return;
}
await addIdentity(element.key, i);
i++;
addIdentity(element.key, i);
},
);
}
@ -120,6 +120,9 @@ class _ProfileViewState extends State<ProfileView> {
@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);
@ -166,68 +169,82 @@ class _ProfileViewState extends State<ProfileView> {
),
);
return SimpleDialog(
alignment: Alignment.center,
title: DisplayName(
account: identities[activeIdentity]!,
openInBrowser: true,
),
contentPadding: const EdgeInsets.all(0),
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();
},
),
),
],
),
Container(
constraints: global.getConstraints(context),
width: global.getWidth(context) +
MediaQuery.of(context).size.width * 0.2,
height: MediaQuery.of(context).size.height * 2 / 3,
child: ListView.builder(
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.all(8.0),
child: Thread(
model: threads[index - 1],
constrained: false,
),
);
}
return const LoadingBox();
},
),
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();
},
),
),
]),
),
],
),
);
}
}
@ -340,14 +357,15 @@ class ProfileViewDisplay extends StatelessWidget {
@override
Widget build(BuildContext context) {
List<Widget> c = [
Image.network(
width: global.getWidth(context),
fit: BoxFit.fitWidth,
accountModel.header,
errorBuilder: (context, error, stackTrace) => const SizedBox.shrink(),
),
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: const EdgeInsets.fromLTRB(8, 0, 8, 0),
padding: themes.defaultMargins,
child: StatusIndicators(
model: accountModel,
relationship: relationshipModel,
@ -355,12 +373,16 @@ class ProfileViewDisplay extends StatelessWidget {
),
d,
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
child: PostTextRenderer(input: accountModel.note),
padding: themes.defaultMargins,
child: PostTextRenderer(
input: accountModel.note,
identityName: accountModel.identity,
emoji: accountModel.emojis,
),
),
if (relationshipModel != null)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
padding: const EdgeInsets.all(themes.defaultSeperatorHeight),
child: AccountInteractionButtons(
account: accountModel,
relationship: relationshipModel!,
@ -369,13 +391,9 @@ class ProfileViewDisplay extends StatelessWidget {
),
];
return Container(
constraints: global.getConstraints(context),
width: global.getWidth(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: c,
),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: c,
);
}
}

View File

@ -1,9 +1,10 @@
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.3 'the profiles update'";
const String version = "v0.5 'the halloween update'";
const String useragent = "$name/$version";
const String website = "https://git.kittycat.homes/zoe/loris";
@ -24,13 +25,13 @@ const List<String> bad = [
];
double getWidth(context) {
return (MediaQuery.of(context).size.width * settings!.postWidth) - 56;
return (MediaQuery.of(context).size.width * settings!.postWidth) -
(themes.defaultSeperatorHeight * 2);
}
BoxConstraints getConstraints(context) {
return BoxConstraints(
maxWidth: settings!.maxPostWidth,
minWidth: 375,
);
}

View File

@ -71,6 +71,27 @@
"you-do-not-follow-each-other": "you don't follow each other",
"found-account-on": "found account on",
"instances": "instances",
"instance": "instace"
"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

@ -15,6 +15,7 @@ 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;

View File

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

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,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:loris/business_logic/notifications/notifs.dart';
import 'package:loris/pages/profile_view/profile_view.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({
@ -13,27 +15,26 @@ 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: [
InkWell(
borderRadius: const BorderRadius.all(themes.defaultRadius),
onTap: () => showDialog(
barrierColor: Colors.transparent,
context: context,
builder: (context) => ProfileView(model: model.account),
),
@ -49,9 +50,10 @@ class SingleNotif extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
model.account.displayName,
style: Theme.of(context).textTheme.displaySmall,
NameDisplay(
emoji: model.account.emojis,
content: model.account.displayName,
style: Theme.of(context).textTheme.displaySmall!,
),
SelectableText.rich(
TextSpan(

View File

@ -1 +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;
@ -42,55 +41,45 @@ class _MainScaffoldState extends State<MainScaffold> {
Widget build(BuildContext context) {
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,16 +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/pages/profile_view/profile_view.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({
@ -35,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) {
@ -56,7 +65,7 @@ class _PostState extends State<Post> {
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: c,
);
}
@ -81,10 +90,17 @@ class DisplayName extends StatelessWidget {
} else {
usernameStyle = Theme.of(context).textTheme.displaySmall;
}
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),
);
@ -101,9 +117,10 @@ class DisplayName extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
account.displayName,
style: usernameStyle,
NameDisplay(
emoji: account.emojis,
content: account.displayName,
style: usernameStyle!,
),
SelectableText(
account.acct,
@ -164,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,
@ -192,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();
@ -217,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: 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),
),
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),
],
),
),
),
],
],
),
),
);
}
@ -289,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,
@ -306,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),
);
@ -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,14 +1,21 @@
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) {
@ -27,19 +34,43 @@ class PostTextRenderer extends StatelessWidget {
backgroundColor: Colors.transparent,
),
a: TextStyle(color: Theme.of(context).colorScheme.secondary),
blockquote: TextStyle(color: Theme.of(context).colorScheme.onBackground),
blockquoteDecoration: 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,
)),
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,6 +3,7 @@ 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({
@ -21,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;
@ -54,42 +71,44 @@ class _ThreadState extends State<Thread> {
}
i++;
}
c.insert(
0,
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()),
),
],
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 Padding(
padding: const EdgeInsets.all(4),
child: Container(
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),
),
child: Column(
children: c,
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,
),
);
}
@ -97,22 +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: global.getConstraints(context),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border:
Border.all(color: Theme.of(context).colorScheme.secondary),
borderRadius: BorderRadius.circular(8),
Container(
clipBehavior: Clip.none,
foregroundDecoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
width: 2,
color: Theme.of(context).colorScheme.secondary,
),
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

@ -2,23 +2,25 @@ 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, 248, 248, 242),
surface: Color.fromARGB(255, 255, 249, 242),
onSurface: Color.fromARGB(255, 25, 25, 25),
));
"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,
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),
));
"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

@ -1,10 +1,24 @@
import 'package:flutter/material.dart';
import 'package:universal_html/html.dart';
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
@ -15,15 +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(
@ -72,13 +90,38 @@ ThemeData getTheme(CustomColors colors) {
color: colors.colorScheme.onSurface,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
foregroundColor: colors.colorScheme.onPrimary,
textStyle: const TextStyle(
fontFamily: 'Atkinson',
fontSize: 18,
fontWeight: FontWeight.w700,
),
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(
@ -99,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),
),
@ -116,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,
@ -128,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),
@ -151,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(
@ -197,6 +263,8 @@ ThemeData getTheme(CustomColors colors) {
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

@ -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
@ -217,6 +231,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
@ -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:
@ -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
@ -410,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:
@ -480,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"
@ -51,6 +51,8 @@ dependencies:
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: