Compare commits

..

70 Commits

Author SHA1 Message Date
107379d9fe 🐛 Bug fixes 2024-10-20 16:15:24 +08:00
0d807b8708 👔 Article wont show expand attachment list 2024-10-19 16:55:14 +08:00
ac1b3fe15c 🐛 Optimize content render 2024-10-19 00:32:16 +08:00
5853de32a2 🐛 Fix localization issue 2024-10-17 23:50:47 +08:00
eac1be365e Birthday celebration screen 2024-10-17 23:49:20 +08:00
3fb1d7a6d4 🚀 Launch 1.4.0+16 2024-10-17 23:01:42 +08:00
0480b5244f 🐛 Fix draft box 2024-10-17 22:44:00 +08:00
56fb92c6b9 🚀 Launch 1.4.0+15 2024-10-16 23:06:31 +08:00
b3267f0026 Summary on search post 2024-10-16 22:49:34 +08:00
88587c10da Notification embed post 2024-10-16 22:38:01 +08:00
9012566dbf 💄 Optimized notification list 2024-10-16 22:32:44 +08:00
6e00a99803 Better attachment fullscreen (support exif meta) 2024-10-16 22:16:03 +08:00
aa17a5d52a 🐛 Bug fixes on notifications 2024-10-16 00:53:29 +08:00
ebeffbe1aa ♻️ Refactored notification 2024-10-16 00:50:48 +08:00
d22eac5c10 🚀 Launch 1.4.0+14 2024-10-16 00:02:36 +08:00
e5381dd5e0 Support more mouse related actions 2024-10-16 00:02:18 +08:00
1c26944a05 🐛 Fix draft box 2024-10-15 21:14:56 +08:00
df787f02a1 🚀 Launch 1.3.8+13 2024-10-14 23:48:15 +08:00
db43b7dca5 💄 Better auto save 2024-10-14 23:08:53 +08:00
59c4d667f6 🐛 Bug fixes on edit content truncated post 2024-10-14 23:04:55 +08:00
063c087089 Post show more button 2024-10-14 22:58:37 +08:00
48e3b510cf Better audit logs 2024-10-14 22:05:49 +08:00
77288713e1 💫 Better loading animation 2024-10-14 21:13:57 +08:00
1abc65f8fa Share post as image on web 2024-10-14 20:51:56 +08:00
a6b17f2c05 Multi-platform support share as image 2024-10-14 13:26:30 +08:00
d8dd4060c0 🚀 Launch 1.3.7+12 2024-10-14 00:39:35 +08:00
c8e131c1ab 🐛 Fix share image size issue 2024-10-14 00:37:42 +08:00
f4621dd2b4 🚀 Launch 1.3.7+11 2024-10-14 00:18:46 +08:00
6e442c144e Chat shell resizable 2024-10-14 00:13:01 +08:00
8bbd964026 🐛 Fix share as image on iPad 2024-10-14 00:10:13 +08:00
0b8a5a3303 💄 Optimize share as image 2024-10-14 00:03:45 +08:00
65c6083640 🚀 Launch 1.3.7+10 2024-10-13 23:19:03 +08:00
ad7a34ec18 Share via image 2024-10-13 23:12:23 +08:00
6c32d76f78 Audit logs 2024-10-13 22:17:23 +08:00
2aa699547c 🐛 Bug fixing on searching 2024-10-13 21:50:47 +08:00
1f4aa8916d Search posts 2024-10-13 21:48:53 +08:00
e2c2e41f89 Improve post detail page first time loading 2024-10-13 21:31:15 +08:00
0f2b854e45 👔 Update level requirements 2024-10-13 20:54:26 +08:00
c21ca5573c Show post visibility 2024-10-13 20:45:00 +08:00
1809f2557d 👽 Support server-side truncate content 2024-10-13 20:36:10 +08:00
1fc84099fe ♻️ Better push token uuid 2024-10-13 20:03:36 +08:00
f8755f5220 🐛 Bug fixes 2024-10-13 19:56:37 +08:00
4041d6dc4e Optimize chat list and attachment loading 2024-10-13 16:26:46 +08:00
cc1071d86e 🚀 Launch 1.3.7+9 2024-10-13 14:58:47 +08:00
e334b862df Auth preferences 2024-10-13 14:13:16 +08:00
32c33a963a 💄 Optimized post list 2024-10-13 01:31:59 +08:00
a04bfe4cf9 🚀 Launch 1.3.7+8 2024-10-12 00:56:30 +08:00
7b7988e6cb ♻️ Refactored post layout 2024-10-12 00:41:03 +08:00
81a616157e 🚀 Launch v1.3.7+7 2024-10-11 01:10:23 +08:00
52312662fb 🐛 Fix post visibility issue 2024-10-11 00:28:09 +08:00
ca18d6ade4 💄 Chat channels loading indicator 2024-10-10 23:48:37 +08:00
af7cc8dab0 💄 Optimize the explore page app bar behavior 2024-10-10 23:36:07 +08:00
382e3c4a4c Optimize post attachment loading 2024-10-10 22:52:05 +08:00
1e37c6ddae 🚀 Launch 1.3.6+6 (to App Store) 2024-10-08 00:52:13 +08:00
442ef06147 💄 Optimize post editor 2024-10-08 00:06:08 +08:00
606a0d708a 🍱 Add cpp highlight file 2024-10-07 23:35:06 +08:00
558828f3e0 🚀 Launch 1.3.6+5 2024-10-07 16:54:29 +08:00
09dc7d2a0d 💄 Brightness of code block 2024-10-07 16:29:36 +08:00
6876d2e7c0 Syntax highlighting in markdown
💄 Optimize content rendering
2024-10-07 16:23:25 +08:00
3a5964730c 🚀 1.3.6+4 2024-10-07 02:12:50 +08:00
271c722df3 🐛 Bug fixes on background image 2024-10-07 01:47:34 +08:00
97656249f2 ⬆️ Upgrade deps 2024-10-06 23:23:11 +08:00
d7e6fe2d8f 💄 More transparency 2024-10-06 23:06:33 +08:00
2e9c4d166e 💄 Optimize designs and bug fixes with background image 2024-10-06 22:38:37 +08:00
c5258cb9ca 🚀 Launch 1.3.6+3 2024-10-06 19:57:17 +08:00
47c535910d 💄 Optimize the style with background image 2024-10-06 19:54:32 +08:00
66f2f33394 🐛 Bug fixes with background image 2024-10-06 19:41:44 +08:00
f5fbe1f483 Better theme & background image 2024-10-06 19:29:47 +08:00
fcf4dc7a2d ♻️ Use unified root container 2024-10-06 17:37:07 +08:00
43b7059957 🐛 Bug fixes and optimization 2024-10-06 17:31:44 +08:00
116 changed files with 6943 additions and 3254 deletions

13
.roadsignrc Normal file
View File

@ -0,0 +1,13 @@
{
"sync": {
"region": "solian",
"configPath": "roadsign.toml"
},
"deployments": [
{
"region": "solian",
"site": "solian-web",
"path": "build/web"
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"cSpell.words": [
"annvisery"
]
}

View File

@ -0,0 +1,358 @@
{
"name": "C++",
"version": "1.0.0",
"fileTypes": ["cpp", "hpp", "cc", "h"],
"scopeName": "source.cpp",
"foldingStartMarker": "\\{\\s*$",
"foldingStopMarker": "^\\s*\\}",
"patterns": [
{
"name": "meta.preprocessor.script.cpp",
"match": "^\\s*#\\s*(include|define|if|ifdef|ifndef|else|endif|pragma)\\b"
},
{
"name": "meta.declaration.cpp",
"begin": "^\\w*\\b(namespace|class|struct|enum|typedef|template)\\b",
"beginCaptures": {
"0": {
"name": "keyword.other.declaration.cpp"
}
},
"end": "(\\{|;)",
"endCaptures": {
"0": {
"name": "punctuation.terminator.cpp"
}
},
"patterns": [
{
"include": "#strings"
},
{
"include": "#comments"
},
{
"name": "keyword.other.cpp",
"match": "\\b(public|private|protected|virtual|override|final)\\b"
}
]
},
{
"include": "#comments"
},
{
"include": "#punctuation"
},
{
"include": "#annotations"
},
{
"include": "#keywords"
},
{
"include": "#constants-and-special-vars"
},
{
"include": "#operators"
},
{
"include": "#strings"
}
],
"repository": {
"comments": {
"patterns": [
{
"name": "comment.block.empty.cpp",
"match": "/\\*\\*/",
"captures": {
"0": {
"name": "punctuation.definition.comment.cpp"
}
}
},
{
"include": "#comments-doc-oldschool"
},
{
"include": "#comments-doc"
},
{
"include": "#comments-inline"
}
]
},
"comments-doc-oldschool": {
"patterns": [
{
"name": "comment.block.documentation.cpp",
"begin": "/\\*\\*",
"end": "\\*/",
"patterns": [
{
"include": "#comments-doc-oldschool"
},
{
"include": "#comments-block"
}
]
}
]
},
"comments-doc": {
"patterns": [
{
"name": "comment.block.documentation.cpp",
"begin": "///",
"while": "^\\s*///",
"patterns": [
{
"include": "#comments-inline"
}
]
}
]
},
"comments-inline": {
"patterns": [
{
"include": "#comments-block"
},
{
"match": "(//.*)$",
"captures": {
"1": {
"name": "comment.line.double-slash.cpp"
}
}
}
]
},
"comments-block": {
"patterns": [
{
"name": "comment.block.cpp",
"begin": "/\\*",
"end": "\\*/",
"patterns": [
{
"include": "#comments-block"
}
]
}
]
},
"annotations": {
"patterns": [
{
"name": "storage.type.annotation.cpp",
"match": "__attribute__\\(\\w+\\)"
}
]
},
"constants-and-special-vars": {
"patterns": [
{
"name": "constant.language.cpp",
"match": "\\b(true|false|nullptr)\\b"
},
{
"name": "variable.language.cpp",
"match": "\\b(this|super)\\b"
},
{
"name": "constant.numeric.cpp",
"match": "\\b((0(x|X)[0-9a-fA-F]+)|(([0-9]+\\.?[0-9]*)|(\\.[0-9]+))((e|E)(\\+|-)?[0-9]+)?)\\b"
},
{
"include": "#class-identifier"
},
{
"include": "#function-identifier"
}
]
},
"class-identifier": {
"patterns": [
{
"match": "\\b(bool|int|char|double|float|long|short|signed|unsigned|void)\\b",
"name": "storage.type.primitive.cpp"
},
{
"begin": "(\\b[A-Z]\\w*\\b)",
"end": "(?!<)",
"beginCaptures": {
"1": {
"name": "support.class.cpp"
}
},
"patterns": [
{
"include": "#type-args"
}
]
}
]
},
"function-identifier": {
"patterns": [
{
"match": "\\b([a-z_][a-zA-Z0-9_]*)\\s*\\(",
"captures": {
"1": {
"name": "entity.name.function.cpp"
}
}
}
]
},
"type-args": {
"begin": "(<)",
"end": "(>)",
"beginCaptures": {
"1": {
"name": "other.source.cpp"
}
},
"endCaptures": {
"1": {
"name": "other.source.cpp"
}
},
"patterns": [
{
"include": "#class-identifier"
},
{
"match": ","
},
{
"name": "keyword.declaration.cpp",
"match": "extends"
},
{
"include": "#comments"
}
]
},
"keywords": {
"patterns": [
{
"name": "keyword.control.cpp",
"match": "\\b(if|else|for|while|do|switch|case|break|continue|goto|return)\\b"
},
{
"name": "keyword.operator.cpp",
"match": "\\b(sizeof|typeid|decltype|new|delete)\\b"
},
{
"name": "keyword.control.try.cpp",
"match": "\\b(try|catch|throw)\\b"
},
{
"name": "keyword.control.cpp",
"match": "\\b(static|inline|virtual|override|const|volatile|explicit|friend|constexpr)\\b"
}
]
},
"operators": {
"patterns": [
{
"name": "keyword.operator.comparison.cpp",
"match": "(==|!=|<=?|>=?)"
},
{
"name": "keyword.operator.arithmetic.cpp",
"match": "(\\+|\\-|\\*|\\/|%)"
},
{
"name": "keyword.operator.assignment.cpp",
"match": "(=|\\+=|-=|\\*=|/=|%=)"
},
{
"name": "keyword.operator.logical.cpp",
"match": "(\\&\\&|\\|\\||!)"
},
{
"name": "keyword.operator.bitwise.cpp",
"match": "(<<|>>|\\&|\\||\\^|~)"
}
]
},
"string-interp": {
"patterns": [
{
"match": "\\$([a-zA-Z0-9_]+)",
"captures": {
"1": {
"name": "variable.parameter.cpp"
}
}
},
{
"name": "string.interpolated.expression.cpp",
"begin": "\\$\\{",
"end": "\\}",
"patterns": [
{
"include": "#constants-and-special-vars",
"name": "variable.parameter.cpp"
},
{
"include": "#strings"
},
{
"name": "variable.parameter.cpp",
"match": "[a-zA-Z0-9_]+"
}
]
},
{
"name": "constant.character.escape.cpp",
"match": "\\\\."
}
]
},
"strings": {
"patterns": [
{
"name": "string.quoted.double.cpp",
"begin": "\"",
"end": "\"",
"patterns": [
{
"name": "constant.character.escape.cpp",
"match": "\\\\."
}
]
},
{
"name": "string.quoted.single.cpp",
"begin": "'",
"end": "'",
"patterns": [
{
"name": "constant.character.escape.cpp",
"match": "\\\\."
}
]
}
]
},
"punctuation": {
"patterns": [
{
"name": "punctuation.comma.cpp",
"match": ","
},
{
"name": "punctuation.terminator.cpp",
"match": ";"
}
]
}
}
}

View File

@ -0,0 +1,531 @@
{
"name": "Dart",
"version": "1.2.3",
"fileTypes": ["dart"],
"scopeName": "source.dart",
"foldingStartMarker": "\\{\\s*$",
"foldingStopMarker": "^\\s*\\}",
"patterns": [
{
"name": "meta.preprocessor.script.dart",
"match": "^(#!.*)$"
},
{
"name": "meta.declaration.dart",
"begin": "^\\w*\\b(library|import|part of|part|export)\\b",
"beginCaptures": {
"0": {
"name": "keyword.other.import.dart"
}
},
"end": ";",
"endCaptures": {
"0": {
"name": "punctuation.terminator.dart"
}
},
"patterns": [
{
"include": "#strings"
},
{
"include": "#comments"
},
{
"name": "keyword.other.import.dart",
"match": "\\b(as|show|hide)\\b"
},
{
"name": "keyword.control.dart",
"match": "\\b(if)\\b"
}
]
},
{
"include": "#comments"
},
{
"include": "#punctuation"
},
{
"include": "#annotations"
},
{
"include": "#keywords"
},
{
"include": "#constants-and-special-vars"
},
{
"include": "#operators"
},
{
"include": "#strings"
}
],
"repository": {
"dartdoc": {
"patterns": [
{
"match": "(\\[.*?\\])",
"captures": {
"0": {
"name": "variable.name.source.dart"
}
}
},
{
"match": "^ {4,}(?![ \\*]).*",
"captures": {
"0": {
"name": "variable.name.source.dart"
}
}
},
{
"contentName": "variable.other.source.dart",
"begin": "```.*?$",
"end": "```"
},
{
"match": "(`.*?`)",
"captures": {
"0": {
"name": "variable.other.source.dart"
}
}
},
{
"match": "(`.*?`)",
"captures": {
"0": {
"name": "variable.other.source.dart"
}
}
},
{
"match": "(\\* (( ).*))$",
"captures": {
"2": {
"name": "variable.other.source.dart"
}
}
}
]
},
"comments": {
"patterns": [
{
"name": "comment.block.empty.dart",
"match": "/\\*\\*/",
"captures": {
"0": {
"name": "punctuation.definition.comment.dart"
}
}
},
{
"include": "#comments-doc-oldschool"
},
{
"include": "#comments-doc"
},
{
"include": "#comments-inline"
}
]
},
"comments-doc-oldschool": {
"patterns": [
{
"name": "comment.block.documentation.dart",
"begin": "/\\*\\*",
"end": "\\*/",
"patterns": [
{
"include": "#comments-doc-oldschool"
},
{
"include": "#comments-block"
},
{
"include": "#dartdoc"
}
]
}
]
},
"comments-doc": {
"patterns": [
{
"name": "comment.block.documentation.dart",
"begin": "///",
"while": "^\\s*///",
"patterns": [
{
"include": "#dartdoc"
}
]
}
]
},
"comments-inline": {
"patterns": [
{
"include": "#comments-block"
},
{
"match": "((//).*)$",
"captures": {
"1": {
"name": "comment.line.double-slash.dart"
}
}
}
]
},
"comments-block": {
"patterns": [
{
"name": "comment.block.dart",
"begin": "/\\*",
"end": "\\*/",
"patterns": [
{
"include": "#comments-block"
}
]
}
]
},
"annotations": {
"patterns": [
{
"name": "storage.type.annotation.dart",
"match": "@[a-zA-Z]+"
}
]
},
"constants-and-special-vars": {
"patterns": [
{
"name": "constant.language.dart",
"match": "(?<!\\$)\\b(true|false|null)\\b(?!\\$)"
},
{
"name": "variable.language.dart",
"match": "(?<!\\$)\\b(this|super)\\b(?!\\$)"
},
{
"name": "constant.numeric.dart",
"match": "(?<!\\$)\\b((0(x|X)[0-9a-fA-F]*)|(([0-9]+\\.?[0-9]*)|(\\.[0-9]+))((e|E)(\\+|-)?[0-9]+)?)\\b(?!\\$)"
},
{
"include": "#class-identifier"
},
{
"include": "#function-identifier"
}
]
},
"class-identifier": {
"patterns": [
{
"match": "(?<!\\$)\\b(bool|num|int|double|dynamic)\\b(?!\\$)",
"name": "support.class.dart"
},
{
"match": "(?<!\\$)\\bvoid\\b(?!\\$)",
"name": "storage.type.primitive.dart"
},
{
"begin": "(?<![a-zA-Z0-9_$])([_$]*[A-Z][a-zA-Z0-9_$]*)\\b",
"end": "(?!<)",
"beginCaptures": {
"1": {
"name": "support.class.dart"
}
},
"patterns": [
{
"include": "#type-args"
}
]
}
]
},
"function-identifier": {
"patterns": [
{
"match": "([_$]*[a-z][a-zA-Z0-9_$]*)(<(?:[a-zA-Z0-9_$<>?]|,\\s*|\\s+extends\\s+)+>)?[!?]?\\(",
"captures": {
"1": {
"name": "entity.name.function.dart"
},
"2": {
"patterns": [
{
"include": "#type-args"
}
]
}
}
}
]
},
"type-args": {
"begin": "(<)",
"end": "(>)",
"beginCaptures": {
"1": {
"name": "other.source.dart"
}
},
"endCaptures": {
"1": {
"name": "other.source.dart"
}
},
"patterns": [
{
"include": "#class-identifier"
},
{
"match": ","
},
{
"name": "keyword.declaration.dart",
"match": "extends"
},
{
"include": "#comments"
}
]
},
"keywords": {
"patterns": [
{
"name": "keyword.cast.dart",
"match": "(?<!\\$)\\bas\\b(?!\\$)"
},
{
"name": "keyword.control.catch-exception.dart",
"match": "(?<!\\$)\\b(try|on|catch|finally|throw|rethrow)\\b(?!\\$)"
},
{
"name": "keyword.control.dart",
"match": "(?<!\\$)\\b(break|case|continue|default|do|else|for|if|in|return|switch|while|when)\\b(?!\\$)"
},
{
"name": "keyword.control.dart",
"match": "(?<!\\$)\\b(sync(\\*)?|async(\\*)?|await|yield(\\*)?)\\b(?!\\$)"
},
{
"name": "keyword.control.dart",
"match": "(?<!\\$)\\bassert\\b(?!\\$)"
},
{
"name": "keyword.control.new.dart",
"match": "(?<!\\$)\\b(new)\\b(?!\\$)"
},
{
"name": "keyword.declaration.dart",
"match": "(?<!\\$)\\b(abstract|sealed|base|interface|class|enum|extends|extension type|extension|external|factory|implements|get(?!\\()|mixin|native|operator|set(?!\\()|typedef|with|covariant)\\b(?!\\$)"
},
{
"name": "storage.modifier.dart",
"match": "(?<!\\$)\\b(static|final|const|required|late)\\b(?!\\$)"
},
{
"name": "storage.type.primitive.dart",
"match": "(?<!\\$)\\b(?:void|var)\\b(?!\\$)"
}
]
},
"operators": {
"patterns": [
{
"name": "keyword.operator.dart",
"match": "(?<!\\$)\\b(is\\!?)\\b(?!\\$)"
},
{
"name": "keyword.operator.ternary.dart",
"match": "\\?|:"
},
{
"name": "keyword.operator.bitwise.dart",
"match": "(<<|>>>?|~|\\^|\\||&)"
},
{
"name": "keyword.operator.assignment.bitwise.dart",
"match": "((&|\\^|\\||<<|>>>?)=)"
},
{
"name": "keyword.operator.closure.dart",
"match": "(=>)"
},
{
"name": "keyword.operator.comparison.dart",
"match": "(==|!=|<=?|>=?)"
},
{
"name": "keyword.operator.assignment.arithmetic.dart",
"match": "(([+*/%-]|\\~)=)"
},
{
"name": "keyword.operator.assignment.dart",
"match": "(=)"
},
{
"name": "keyword.operator.increment-decrement.dart",
"match": "(\\-\\-|\\+\\+)"
},
{
"name": "keyword.operator.arithmetic.dart",
"match": "(\\-|\\+|\\*|\\/|\\~\\/|%)"
},
{
"name": "keyword.operator.logical.dart",
"match": "(!|&&|\\|\\|)"
}
]
},
"string-interp": {
"patterns": [
{
"match": "\\$([a-zA-Z0-9_]+)",
"captures": {
"1": {
"name": "variable.parameter.dart"
}
}
},
{
"name": "string.interpolated.expression.dart",
"begin": "\\$\\{",
"end": "\\}",
"patterns": [
{
"include": "#constants-and-special-vars",
"name": "variable.parameter.dart"
},
{
"include": "#strings"
},
{
"name": "variable.parameter.dart",
"match": "[a-zA-Z0-9_]+"
}
]
},
{
"name": "constant.character.escape.dart",
"match": "\\\\."
}
]
},
"strings": {
"patterns": [
{
"name": "string.interpolated.triple.double.dart",
"begin": "(?<!r)\"\"\"",
"end": "\"\"\"(?!\")",
"patterns": [
{
"include": "#string-interp"
}
]
},
{
"name": "string.interpolated.triple.single.dart",
"begin": "(?<!r)'''",
"end": "'''(?!')",
"patterns": [
{
"include": "#string-interp"
}
]
},
{
"name": "string.quoted.triple.double.dart",
"begin": "r\"\"\"",
"end": "\"\"\"(?!\")"
},
{
"name": "string.quoted.triple.single.dart",
"begin": "r'''",
"end": "'''(?!')"
},
{
"name": "string.interpolated.double.dart",
"begin": "(?<!\\|r)\"",
"end": "\"",
"patterns": [
{
"name": "invalid.string.newline",
"match": "\\n"
},
{
"include": "#string-interp"
}
]
},
{
"name": "string.quoted.double.dart",
"begin": "r\"",
"end": "\"",
"patterns": [
{
"name": "invalid.string.newline",
"match": "\\n"
}
]
},
{
"name": "string.interpolated.single.dart",
"begin": "(?<!\\|r)'",
"end": "'",
"patterns": [
{
"name": "invalid.string.newline",
"match": "\\n"
},
{
"include": "#string-interp"
}
]
},
{
"name": "string.quoted.single.dart",
"begin": "r'",
"end": "'",
"patterns": [
{
"name": "invalid.string.newline",
"match": "\\n"
}
]
}
]
},
"punctuation": {
"patterns": [
{
"name": "punctuation.comma.dart",
"match": ","
},
{
"name": "punctuation.terminator.dart",
"match": ";"
},
{
"name": "punctuation.dot.dart",
"match": "\\."
}
]
}
}
}

View File

@ -0,0 +1,212 @@
{
"fileTypes": ["json"],
"foldingStartMarker": "^\\s*[{\\[](?!.*[}\\]],?\\s*$)|[{\\[]\\s*$",
"foldingStopMarker": "^\\s*[}\\]]",
"keyEquivalent": "^~J",
"name": "JSON (Javascript Next)",
"patterns": [
{
"include": "#value"
}
],
"repository": {
"array": {
"begin": "\\[",
"beginCaptures": {
"0": {
"name": "punctuation.definition.array.begin.json"
}
},
"end": "\\]",
"endCaptures": {
"0": {
"name": "punctuation.definition.array.end.json"
}
},
"name": "meta.structure.array.json",
"patterns": [
{
"include": "#value"
},
{
"match": ",",
"name": "punctuation.separator.array.json"
},
{
"match": "[^\\s\\]]",
"name": "invalid.illegal.expected-array-separator.json"
}
]
},
"comments": {
"patterns": [
{
"begin": "/\\*\\*",
"captures": {
"0": {
"name": "punctuation.definition.comment.json"
}
},
"end": "\\*/",
"name": "comment.block.documentation.json"
},
{
"begin": "/\\*",
"captures": {
"0": {
"name": "punctuation.definition.comment.json"
}
},
"end": "\\*/",
"name": "comment.block.json"
},
{
"captures": {
"1": {
"name": "punctuation.definition.comment.json"
}
},
"match": "(//).*$\\n?",
"name": "comment.line.double-slash.js"
}
]
},
"constant": {
"match": "\\b(?:true|false|null)\\b",
"name": "constant.language.json"
},
"number": {
"match": "-?(?:0|[1-9]\\d*)\n(?:\n(?:\n\\.\\d+)?\n(?:\n[eE][+-]?\\d+)?)?",
"name": "constant.numeric.json"
},
"object": {
"begin": "\\{",
"beginCaptures": {
"0": {
"name": "punctuation.definition.dictionary.begin.json"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.dictionary.end.json"
}
},
"name": "meta.structure.dictionary.json",
"patterns": [
{
"comment": "the JSON object key",
"include": "#objectkey"
},
{
"include": "#comments"
},
{
"begin": ":",
"beginCaptures": {
"0": {
"name": "punctuation.separator.dictionary.key-value.json"
}
},
"end": "(,)|(?=\\})",
"endCaptures": {
"1": {
"name": "punctuation.separator.dictionary.pair.json"
}
},
"name": "meta.structure.dictionary.value.json",
"patterns": [
{
"comment": "the JSON object value",
"include": "#value"
},
{
"match": "[^\\s,]",
"name": "invalid.illegal.expected-dictionary-separator.json"
}
]
},
{
"match": "[^\\s\\}]",
"name": "invalid.illegal.expected-dictionary-separator.json"
}
]
},
"string": {
"begin": "\"",
"beginCaptures": {
"0": {
"name": "punctuation.definition.string.begin.json"
}
},
"end": "\"",
"endCaptures": {
"0": {
"name": "punctuation.definition.string.end.json"
}
},
"name": "string.quoted.double.json",
"patterns": [
{
"include": "#stringcontent"
}
]
},
"objectkey": {
"begin": "\"",
"beginCaptures": {
"0": {
"name": "punctuation.support.type.property-name.begin.json"
}
},
"end": "\"",
"endCaptures": {
"0": {
"name": "punctuation.support.type.property-name.end.json"
}
},
"name": "support.type.property-name.json",
"patterns": [
{
"include": "#stringcontent"
}
]
},
"stringcontent": {
"patterns": [
{
"match": "\\\\(?:[\"\\\\/bfnrt]|u[0-9a-fA-F]{4})",
"name": "constant.character.escape.json"
},
{
"match": "\\\\.",
"name": "invalid.illegal.unrecognized-string-escape.json"
}
]
},
"value": {
"patterns": [
{
"include": "#constant"
},
{
"include": "#number"
},
{
"include": "#string"
},
{
"include": "#array"
},
{
"include": "#object"
},
{
"include": "#comments"
}
]
}
},
"scopeName": "source.json",
"uuid": "8f97457b-516e-48ce-83c7-08ae12fb327a"
}

View File

@ -0,0 +1,98 @@
{
"name": "Python",
"version": "1.0.0",
"fileTypes": ["py"],
"scopeName": "source.python",
"foldingStartMarker": "\\b(?:def|class)\\s*[^:]*:\\s*$",
"foldingStopMarker": "^\\s*\\}",
"patterns": [
{ "include": "#comments" },
{ "include": "#keywords" },
{ "include": "#constants-and-special-vars" },
{ "include": "#operators" },
{ "include": "#strings" }
],
"repository": {
"comments": {
"patterns": [
{ "name": "comment.line.hash.python", "match": "#.*$" },
{ "name": "comment.block.python", "begin": "'''", "end": "'''" },
{ "name": "comment.block.python", "begin": "\"\"\"", "end": "\"\"\"" }
]
},
"keywords": {
"patterns": [
{
"name": "keyword.control.python",
"match": "\\b(?:if|else|while|for|in|break|continue|return)\\b"
},
{
"name": "keyword.operator.logical.python",
"match": "\\b(?:and|or|not)\\b"
},
{ "name": "keyword.operator.assignment.python", "match": "=" },
{ "name": "storage.modifier.python", "match": "\\b(?:def|class)\\b" }
]
},
"constants-and-special-vars": {
"patterns": [
{
"name": "constant.language.python",
"match": "\\b(?:True|False|None)\\b"
},
{ "name": "variable.language.python", "match": "\\b(?:self)\\b" },
{
"name": "constant.numeric.python",
"match": "\\b(?:\\d+\\.?\\d*|\\.\\d+)\\b"
}
]
},
"operators": {
"patterns": [
{
"name": "keyword.operator.arithmetic.python",
"match": "\\b(?:\\+|-|\\*|/|%|//)\\b"
},
{
"name": "keyword.operator.comparison.python",
"match": "\\b(?:==|!=|<|<=|>|>=)\\b"
},
{
"name": "keyword.operator.logical.python",
"match": "\\b(?:and|or|not)\\b"
}
]
},
"strings": {
"patterns": [
{
"name": "string.quoted.triple.double.python",
"begin": "\"\"\"",
"end": "\"\"\""
},
{
"name": "string.quoted.triple.single.python",
"begin": "'''",
"end": "'''"
},
{
"name": "string.quoted.double.python",
"begin": "\"",
"end": "\"",
"patterns": [{ "include": "#string-escape" }]
},
{
"name": "string.quoted.single.python",
"begin": "'",
"end": "'",
"patterns": [{ "include": "#string-escape" }]
}
]
},
"string-escape": {
"patterns": [
{ "name": "constant.character.escape.python", "match": "\\\\[\"']" }
]
}
}
}

View File

@ -0,0 +1,145 @@
{
"fileTypes": ["sql", "ddl", "dml"],
"foldingStartMarker": "(?i)^\\s*(begin|if|loop)\\b",
"foldingStopMarker": "(?i)^\\s*(end)\\b",
"keyEquivalent": "^~S",
"name": "PL/pgSQL (Postgres)",
"patterns": [
{
"begin": "/\\*",
"end": "\\*/",
"name": "comment.block.postgres"
},
{
"match": "--.*$",
"name": "comment.line.double-dash.postgres"
},
{
"captures": {
"1": {
"name": "keyword.other.postgres"
},
"2": {
"name": "keyword.other.postgres"
}
},
"match": "(?i)^\\s*(create)(\\s+or\\s+replace)?\\s+",
"name": "meta.create.postgres"
},
{
"captures": {
"1": {
"name": "keyword.other.postgres"
},
"2": {
"name": "keyword.other.postgres"
},
"3": {
"name": "entity.name.type.postgres"
}
},
"match": "(?i)\\b(package)(\\s+body)?\\s+(\\S+)",
"name": "meta.package.postgres"
},
{
"captures": {
"1": {
"name": "keyword.other.postgres"
},
"2": {
"name": "entity.name.type.postgres"
}
},
"match": "(?i)\\b(type)\\s+\"([^\"]+)\"",
"name": "meta.type.postgres"
},
{
"captures": {
"1": {
"name": "keyword.other.postgres"
},
"2": {
"name": "entity.name.function.postgres"
}
},
"match": "(?i)\\s*(function|procedure)\\s+([-a-z0-9_.]+)",
"name": "meta.procedure.postgres"
},
{
"match": "[!<>:]?=|<>|<|>|\\+|(?<!\\.)\\*|-|(?<!^)/|@@|\\|\\|",
"name": "keyword.operator.postgres"
},
{
"match": "(?i)\\b(true|false|null|found)\\b",
"name": "constant.language.postgres"
},
{
"match": "\\b\\d+(\\.\\d+)?\\b",
"name": "constant.numeric.postgres"
},
{
"match": "(?i)\\b(if|elsif|else|end\\s+if|loop|end\\s+loop|for|foreach|array|case|end\\s+case|continue|return|goto|alias)\\b",
"name": "keyword.control.postgres"
},
{
"match": "(?i)\\b(or|and|not|like)\\b",
"name": "keyword.operator.postgres"
},
{
"match": "(?i)\\b(sysdate|%(isopen|found|notfound|rowcount)|commit|rollback|sqlerrm|substr|cast|decode|length|lower|upper|coalesce)\\b",
"name": "support.function.postgres"
},
{
"match": "(?i)\\b(avg|count|sum|max|min|nvl|trim|to_date|to_char|lpad|ltrim|rpad|rtrim|trunc|to_number|regexp_split_to_array|regexp_replace)\\b",
"name": "support.function.builtin.postgres"
},
{
"match": "(?i)\\b(sql|sqlcode)\\b",
"name": "variable.language.postgres"
},
{
"match": "(?i)\\b(p(i|o|io)_[-a-z0-9_]+)\\b",
"name": "variable.parameter.postgres"
},
{
"match": "(?i)\\b(l_[-a-z0-9_]+)\\b",
"name": "variable.other.postgres"
},
{
"match": "(?i)\\b(immutable|volatile|stable|serial|primary|key|references|comment|column|schema|authorization|get|diagnostics|returning|drop|all|raise|notice|warning|exception|external|security|definer|language|grant|execute|on|to|function|procedure|returns|end|then|deterministic|exception|when|others|subtype|constant|range|binary_integer|declare|begin|in|out|is|as|exit|open|fetch|into|close|type|rowtype|default|\\.(extend|count|first|last|next|nextval|currval)|cost|alter|owner)\\b",
"name": "keyword.other.postgres"
},
{
"match": "(?i)\\b(select|perform|from|where|order\\s+by|group\\s+by|asc|desc|update|set|insert|into|values|delete|from|distinct|union|having|limit|table|of|prepare|(inner|left|outer) join)\\b",
"name": "keyword.other.sql.postgres"
},
{
"match": "[$][0-9]+",
"name": "storage.type.postgres"
},
{
"match": "(?i)\\b(dbms_lock|dbms_output)\\b",
"name": "support.class.postgres"
},
{
"match": "(?i)\\b(put_line)\\b",
"name": "support.function.postgres"
},
{
"begin": "'",
"end": "'",
"name": "string.quoted.single.postgres"
},
{
"begin": "\"",
"end": "\"",
"name": "string.quoted.double.postgres"
},
{
"match": "(?i)\\b(number|integer|bigint|varchar2|varchar|boolean|date|setof|record|query|numeric|void|character varying|text|([-a-z0-9_.]+%(row)?type))\\b",
"name": "storage.type.postgres"
}
],
"scopeName": "source.plpgsql.postgres",
"uuid": "28DCE4DD-F5E1-4ED3-8847-64DA6B1F9163"
}

View File

@ -0,0 +1,66 @@
{
"name": "YAML",
"fileTypes": ["yaml", "yml"],
"scopeName": "source.yaml",
"patterns": [
{
"name": "comment.line.number-sign.yaml",
"match": "#.*",
"captures": {
"0": {
"name": "punctuation.definition.comment.yaml"
}
}
},
{
"name": "entity.name.tag.yaml",
"match": "^\\s*\\w+",
"captures": {
"0": {
"name": "punctuation.definition.tag.yaml"
}
}
},
{
"name": "punctuation.separator.key-value.yaml",
"match": ":",
"captures": {
"0": {
"name": "punctuation.separator.key-value.yaml"
}
}
},
{
"name": "string.quoted.double.yaml",
"begin": "\"",
"end": "\"",
"patterns": [
{
"name": "constant.character.escape.yaml",
"match": "\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{6}|.)"
}
]
},
{
"name": "string.quoted.single.yaml",
"begin": "'",
"end": "'",
"patterns": [
{
"name": "constant.character.escape.yaml",
"match": "''"
}
]
}
],
"repository": {
"scalar-plain": {
"patterns": [
{
"match": "\\b(\\w+)\\b",
"name": "scalar.plain.yaml"
}
]
}
}
}

View File

@ -54,6 +54,7 @@
"about": "About", "about": "About",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"insert": "Insert",
"settings": "Settings", "settings": "Settings",
"settingsNotificationBgService": "Background notification service", "settingsNotificationBgService": "Background notification service",
"settingsNotificationBgServiceDesc": "A notification service is always installed on the device, so that some devices that do not support push notifications can receive notifications in the background. When this feature is enabled, push notifications will not be registered with the server, and you will always appear to be online in the eyes of others (except for invisible). You may need to turn off power and traffic optimization in the settings.", "settingsNotificationBgServiceDesc": "A notification service is always installed on the device, so that some devices that do not support push notifications can receive notifications in the background. When this feature is enabled, push notifications will not be registered with the server, and you will always appear to be online in the eyes of others (except for invisible). You may need to turn off power and traffic optimization in the settings.",
@ -140,7 +141,7 @@
"clear": "Clear", "clear": "Clear",
"pinPost": "Pin this post", "pinPost": "Pin this post",
"unpinPost": "Unpin this post", "unpinPost": "Unpin this post",
"postRestoreFromLocal": "Restore from local", "postRestoreFromLocal": "Restored",
"postAutoSaveAt": "Auto saved at @date", "postAutoSaveAt": "Auto saved at @date",
"postCategoriesAndTags": "Categories n' Tags", "postCategoriesAndTags": "Categories n' Tags",
"postPublishDate": "Publish Date", "postPublishDate": "Publish Date",
@ -366,8 +367,9 @@
"bsPreparingData": "Preparing User Data", "bsPreparingData": "Preparing User Data",
"bsRegisteringPushNotify": "Enabling Push Notifications", "bsRegisteringPushNotify": "Enabling Push Notifications",
"bsDismissibleErrorHint": "Click anywhere to ignore this error", "bsDismissibleErrorHint": "Click anywhere to ignore this error",
"bsContinuable": "Click anywhere to continue",
"postShareContent": "@content\n\n@username on the Solar Network\nCheck it out: @link", "postShareContent": "@content\n\n@username on the Solar Network\nCheck it out: @link",
"postShareSubject": "@username posted a post on the Solar Network", "postShareSubject": "@title by @username on Solar Network",
"themeColor": "Global Theme Color", "themeColor": "Global Theme Color",
"themeColorRed": "Modern Red", "themeColorRed": "Modern Red",
"themeColorBlue": "Classic Blue", "themeColorBlue": "Classic Blue",
@ -468,5 +470,29 @@
"all": "All", "all": "All",
"unablePreview": "Unable to preview", "unablePreview": "Unable to preview",
"dashboardNav": "Dash", "dashboardNav": "Dash",
"accountNav": "You" "accountNav": "You",
"performance": "Performance",
"animatedMessageList": "Non-animated message list",
"animatedMessageListDesc": "Remove animation effects in message list, to reduce cause lag",
"theme": "Theme",
"globalTheme": "Global theme",
"agedTheme": "Old school style theme",
"agedThemeDesc": "Downgrade the global theme to Material Design 2. Unexpected issues may occur. For experimental use only.",
"appBackgroundImage": "Global background image",
"appBackgroundImageDesc": "The global background image will be displayed on all pages",
"authPreferences": "Auth preferences",
"authPreferencesDesc": "Set the security behavior of your account",
"authMaximumAuthSteps": "Maximum authentication steps",
"authMaximumAuthStepsDesc": "The maximum number of authentication steps when logging in, higher value is more secure, lower value is more convenient; default is 2",
"auditLog": "Audit log",
"shareImage": "Share as image",
"shareImageFooter": "Only on the Solar Network",
"fileSavedAt": "File saved at @path",
"showIp": "Show IP Address",
"shotOn": "Shot on @device",
"unread": "Unread",
"searchTook": "Took @time",
"searchResult": "@count Matches",
"happyBirthday": "Happy birthday @name!",
"happyBirthdayDesc": "Today is your @count birthday"
} }

View File

@ -14,6 +14,7 @@
"about": "关于", "about": "关于",
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
"insert": "插入",
"settings": "设置", "settings": "设置",
"settingsNotificationBgService": "常驻通知服务", "settingsNotificationBgService": "常驻通知服务",
"settingsNotificationBgServiceDesc": "在设备常驻一个通知服务,使得部分不支持推送通知的设备可以在后台收到通知;启用该功能的情况下不会向服务器注册推送通知,并且你会始终在他人眼中成为在线(隐身除外);可能需要在设置中关闭电量与流量优化。", "settingsNotificationBgServiceDesc": "在设备常驻一个通知服务,使得部分不支持推送通知的设备可以在后台收到通知;启用该功能的情况下不会向服务器注册推送通知,并且你会始终在他人眼中成为在线(隐身除外);可能需要在设置中关闭电量与流量优化。",
@ -266,7 +267,7 @@
"channelMembersAddHint": "到 @channel", "channelMembersAddHint": "到 @channel",
"channelType": "频道类型", "channelType": "频道类型",
"channelTypeCommon": "普通频道", "channelTypeCommon": "普通频道",
"channelTypeDirect": "私信聊天", "channelTypeDirect": "私信",
"channelAdjust": "调整频道", "channelAdjust": "调整频道",
"channelDetail": "频道详情", "channelDetail": "频道详情",
"channelSettings": "频道设置", "channelSettings": "频道设置",
@ -362,8 +363,9 @@
"bsPreparingData": "正在准备用户资料", "bsPreparingData": "正在准备用户资料",
"bsRegisteringPushNotify": "正在启用推送通知", "bsRegisteringPushNotify": "正在启用推送通知",
"bsDismissibleErrorHint": "点击任意地方忽略此错误", "bsDismissibleErrorHint": "点击任意地方忽略此错误",
"bsContinuable": "点击任意处继续",
"postShareContent": "@content\n\n@username 在 Solar Network\n原帖地址@link", "postShareContent": "@content\n\n@username 在 Solar Network\n原帖地址@link",
"postShareSubject": "@username 在 Solar Network 上发布了一篇帖子", "postShareSubject": "@username 在 Solar Network 发表的 @title",
"themeColor": "全局主题色", "themeColor": "全局主题色",
"themeColorRed": "现代红", "themeColorRed": "现代红",
"themeColorBlue": "经典蓝", "themeColorBlue": "经典蓝",
@ -464,5 +466,29 @@
"all": "全部", "all": "全部",
"unablePreview": "无法预览", "unablePreview": "无法预览",
"dashboardNav": "仪表盘", "dashboardNav": "仪表盘",
"accountNav": "您" "accountNav": "您",
"performance": "性能",
"animatedMessageList": "无动画消息列表",
"animatedMessageListDesc": "在消息列表中禁用动画效果",
"theme": "主题",
"globalTheme": "全局应用主题",
"agedTheme": "过时主题",
"agedThemeDesc": "将全局主题降级为 Material Design 2可能发生意料之外的问题仅供实验使用",
"appBackgroundImage": "全局背景图片",
"appBackgroundImageDesc": "全局背景图片将会在所有页面中展示",
"authPreferences": "安全偏好设置",
"authPreferencesDesc": "调整账号的安全行为模式",
"authMaximumAuthSteps": "最大认证步数",
"authMaximumAuthStepsDesc": "登陆时最多的验证步数,值越高则越安全,反之则会相对方便;默认设置为 2",
"auditLog": "活动日志",
"shareImage": "分享图片",
"shareImageFooter": "上 Solar Network 看更多有趣帖子",
"fileSavedAt": "文件保存于 @path",
"showIp": "显示 IP 地址",
"shotOn": "由 @device 拍摄",
"unread": "未读",
"searchTook": "耗时 @time",
"searchResult": "匹配到 @count 条结果",
"happyBirthday": "生日快乐,@name!",
"happyBirthdayDesc": "今天是你的第 @count 个生日"
} }

View File

@ -38,45 +38,47 @@ PODS:
- file_picker (0.0.1): - file_picker (0.0.1):
- DKImagePickerController/PhotoGallery - DKImagePickerController/PhotoGallery
- Flutter - Flutter
- Firebase/Analytics (11.0.0): - file_saver (0.0.1):
- Flutter
- Firebase/Analytics (11.2.0):
- Firebase/Core - Firebase/Core
- Firebase/Core (11.0.0): - Firebase/Core (11.2.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseAnalytics (~> 11.0.0) - FirebaseAnalytics (~> 11.2.0)
- Firebase/CoreOnly (11.0.0): - Firebase/CoreOnly (11.2.0):
- FirebaseCore (= 11.0.0) - FirebaseCore (= 11.2.0)
- Firebase/Crashlytics (11.0.0): - Firebase/Crashlytics (11.2.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseCrashlytics (~> 11.0.0) - FirebaseCrashlytics (~> 11.2.0)
- Firebase/Messaging (11.0.0): - Firebase/Messaging (11.2.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseMessaging (~> 11.0.0) - FirebaseMessaging (~> 11.2.0)
- Firebase/Performance (11.0.0): - Firebase/Performance (11.2.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebasePerformance (~> 11.0.0) - FirebasePerformance (~> 11.2.0)
- firebase_analytics (11.3.2): - firebase_analytics (11.3.3):
- Firebase/Analytics (= 11.0.0) - Firebase/Analytics (= 11.2.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_core (3.5.0): - firebase_core (3.6.0):
- Firebase/CoreOnly (= 11.0.0) - Firebase/CoreOnly (= 11.2.0)
- Flutter - Flutter
- firebase_crashlytics (4.1.2): - firebase_crashlytics (4.1.3):
- Firebase/Crashlytics (= 11.0.0) - Firebase/Crashlytics (= 11.2.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_messaging (15.1.2): - firebase_messaging (15.1.3):
- Firebase/Messaging (= 11.0.0) - Firebase/Messaging (= 11.2.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_performance (0.10.0-7): - firebase_performance (0.10.0-8):
- Firebase/Performance (= 11.0.0) - Firebase/Performance (= 11.2.0)
- firebase_core - firebase_core
- Flutter - Flutter
- FirebaseABTesting (11.2.0): - FirebaseABTesting (11.3.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseAnalytics (11.0.0): - FirebaseAnalytics (11.2.0):
- FirebaseAnalytics/AdIdSupport (= 11.0.0) - FirebaseAnalytics/AdIdSupport (= 11.2.0)
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@ -84,24 +86,24 @@ PODS:
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- FirebaseAnalytics/AdIdSupport (11.0.0): - FirebaseAnalytics/AdIdSupport (11.2.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- GoogleAppMeasurement (= 11.0.0) - GoogleAppMeasurement (= 11.2.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- FirebaseCore (11.0.0): - FirebaseCore (11.2.0):
- FirebaseCoreInternal (~> 11.0) - FirebaseCoreInternal (~> 11.0)
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0) - GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreExtension (11.2.0): - FirebaseCoreExtension (11.3.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseCoreInternal (11.2.0): - FirebaseCoreInternal (11.3.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseCrashlytics (11.0.0): - FirebaseCrashlytics (11.2.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfigInterop (~> 11.0) - FirebaseRemoteConfigInterop (~> 11.0)
@ -110,12 +112,12 @@ PODS:
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4) - PromisesObjC (~> 2.4)
- FirebaseInstallations (11.2.0): - FirebaseInstallations (11.3.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4) - PromisesObjC (~> 2.4)
- FirebaseMessaging (11.0.0): - FirebaseMessaging (11.2.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- GoogleDataTransport (~> 10.0) - GoogleDataTransport (~> 10.0)
@ -124,7 +126,7 @@ PODS:
- GoogleUtilities/Reachability (~> 8.0) - GoogleUtilities/Reachability (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- FirebasePerformance (11.0.0): - FirebasePerformance (11.2.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
- FirebaseRemoteConfig (~> 11.0) - FirebaseRemoteConfig (~> 11.0)
@ -134,7 +136,7 @@ PODS:
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- FirebaseRemoteConfig (11.2.0): - FirebaseRemoteConfig (11.3.0):
- FirebaseABTesting (~> 11.0) - FirebaseABTesting (~> 11.0)
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
@ -142,8 +144,8 @@ PODS:
- FirebaseSharedSwift (~> 11.0) - FirebaseSharedSwift (~> 11.0)
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseRemoteConfigInterop (11.2.0) - FirebaseRemoteConfigInterop (11.3.0)
- FirebaseSessions (11.2.0): - FirebaseSessions (11.3.0):
- FirebaseCore (~> 11.0) - FirebaseCore (~> 11.0)
- FirebaseCoreExtension (~> 11.0) - FirebaseCoreExtension (~> 11.0)
- FirebaseInstallations (~> 11.0) - FirebaseInstallations (~> 11.0)
@ -152,7 +154,7 @@ PODS:
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1) - PromisesSwift (~> 2.1)
- FirebaseSharedSwift (11.2.0) - FirebaseSharedSwift (11.3.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_app_update (0.0.1): - flutter_app_update (0.0.1):
- Flutter - Flutter
@ -166,27 +168,30 @@ PODS:
- Flutter - Flutter
- flutter_secure_storage (6.0.0): - flutter_secure_storage (6.0.0):
- Flutter - Flutter
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
- flutter_webrtc (0.11.3): - flutter_webrtc (0.11.3):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.04) - WebRTC-SDK (= 125.6422.04)
- gal (1.0.0): - gal (1.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- GoogleAppMeasurement (11.0.0): - GoogleAppMeasurement (11.2.0):
- GoogleAppMeasurement/AdIdSupport (= 11.0.0) - GoogleAppMeasurement/AdIdSupport (= 11.2.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- GoogleAppMeasurement/AdIdSupport (11.0.0): - GoogleAppMeasurement/AdIdSupport (11.2.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.0.0) - GoogleAppMeasurement/WithoutAdIdSupport (= 11.2.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)" - "GoogleUtilities/NSData+zlib (~> 8.0)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- GoogleAppMeasurement/WithoutAdIdSupport (11.0.0): - GoogleAppMeasurement/WithoutAdIdSupport (11.2.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- GoogleUtilities/MethodSwizzler (~> 8.0) - GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/Network (~> 8.0) - GoogleUtilities/Network (~> 8.0)
@ -259,6 +264,7 @@ PODS:
- PromisesObjC (= 2.4.0) - PromisesObjC (= 2.4.0)
- protocol_handler_ios (0.0.1): - protocol_handler_ios (0.0.1):
- Flutter - Flutter
- SAMKeychain (1.5.3)
- screen_brightness_ios (0.1.0): - screen_brightness_ios (0.1.0):
- Flutter - Flutter
- SDWebImage (5.19.7): - SDWebImage (5.19.7):
@ -269,7 +275,7 @@ PODS:
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- sqflite (0.0.3): - sqflite_darwin (0.0.4):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- "sqlite3 (3.46.1+1)": - "sqlite3 (3.46.1+1)":
@ -304,6 +310,7 @@ DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_saver (from `.symlinks/plugins/file_saver/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
@ -316,6 +323,7 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`) - gal (from `.symlinks/plugins/gal/darwin`)
- image_cropper (from `.symlinks/plugins/image_cropper/ios`) - image_cropper (from `.symlinks/plugins/image_cropper/ios`)
@ -334,7 +342,7 @@ DEPENDENCIES:
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`) - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
@ -364,6 +372,7 @@ SPEC REPOS:
- nanopb - nanopb
- PromisesObjC - PromisesObjC
- PromisesSwift - PromisesSwift
- SAMKeychain
- SDWebImage - SDWebImage
- sqlite3 - sqlite3
- SwiftyGif - SwiftyGif
@ -377,6 +386,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/device_info_plus/ios" :path: ".symlinks/plugins/device_info_plus/ios"
file_picker: file_picker:
:path: ".symlinks/plugins/file_picker/ios" :path: ".symlinks/plugins/file_picker/ios"
file_saver:
:path: ".symlinks/plugins/file_saver/ios"
firebase_analytics: firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios" :path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core: firebase_core:
@ -401,6 +412,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_native_splash/ios" :path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage: flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_webrtc: flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios" :path: ".symlinks/plugins/flutter_webrtc/ios"
gal: gal:
@ -437,8 +450,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_plus/ios" :path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite: sqflite_darwin:
:path: ".symlinks/plugins/sqflite/darwin" :path: ".symlinks/plugins/sqflite_darwin/darwin"
sqlite3_flutter_libs: sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/ios" :path: ".symlinks/plugins/sqlite3_flutter_libs/ios"
url_launcher_ios: url_launcher_ios:
@ -449,30 +462,31 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios" :path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Firebase: 9f574c08c2396885b5e7e100ed4293d956218af9 file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
firebase_analytics: 4fd10182fd08bb8358f26ac8aca8dad7b6d0f592 Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c
firebase_core: 2ec6b789859c7c24766344ec71fdf78639402d56 firebase_analytics: fbc57838bdb94eef1e0ff504f127d974ff2981ad
firebase_crashlytics: 60630a0f91ee432275fa1660fd8593079761448a firebase_core: 2bedc3136ec7c7b8561c6123ed0239387b53f2af
firebase_messaging: a18e1e02b2e8e69097c8173e0c851be223b21c50 firebase_crashlytics: 37d104d457b51760b48504a93a12b3bf70995d77
firebase_performance: 12d45fdf120992fa879d990929bf73d4a5ced053 firebase_messaging: 15d114e1a41fc31e4fbabcd48d765a19eec94a38
FirebaseABTesting: 2104d957ce33888a3d6f3bde298cdee376dde8f1 firebase_performance: 26ad47755d3e8d7b04b9bb36bdfbf1cec8d8dfcc
FirebaseAnalytics: 27eb78b97880ea4a004839b9bac0b58880f5a92a FirebaseABTesting: c4559fcd2eba9f6bdaf0599e2c37ded01c343e4c
FirebaseCore: 3cf438f431f18c12cdf2aaf64434648b63f7e383 FirebaseAnalytics: c36efd5710c60c17558650fa58c2066eca7e9265
FirebaseCoreExtension: cda74ddfb001224bd8fd1d6e74698b4ed07803de FirebaseCore: a282032ae9295c795714ded2ec9c522fc237f8da
FirebaseCoreInternal: 0c569513412da9f3b31bd0b340013bbee8f295c5 FirebaseCoreExtension: 30bb063476ef66cd46925243d64ad8b2c8ac3264
FirebaseCrashlytics: 745d8f0221fe49c62865391d1bf56f5a12eeec0b FirebaseCoreInternal: ac26d09a70c730e497936430af4e60fb0c68ec4e
FirebaseInstallations: 771177d89d6c451dc6e50085ec82e2fc77ed0a4a FirebaseCrashlytics: cfc69af5b53565dc6a5e563788809b5778ac4eac
FirebaseMessaging: d2d1d9c62c46dd2db49a952f7deb5b16ad2c9742 FirebaseInstallations: 58cf94dabf1e2bb2fa87725a9be5c2249171cda0
FirebasePerformance: efdc02bacb1b4710588c9f867011605c081cdf79 FirebaseMessaging: c9ec7b90c399c7a6100297e9d16f8a27fc7f7152
FirebaseRemoteConfig: fca0b2d017fc1de52b28a4e5bcf2007c1a840457 FirebasePerformance: c39138c0700b8ef6040f0b80b5707320808e2862
FirebaseRemoteConfigInterop: 477b26fdeb8fb5fbaf22fa9db5343b42289dc7db FirebaseRemoteConfig: 5be2ca4f9870d475b39214210955fdaeecf7e5ca
FirebaseSessions: adcec8b72d0066a385e3affcd1bcb1ebb3908ce6 FirebaseRemoteConfigInterop: c3a5c31b3c22079f41ba1dc645df889d9ce38cb9
FirebaseSharedSwift: 7a0d78d155ede78407f0fdc89fbc914014c7c540 FirebaseSessions: 655ff17f3cc1a635cbdc2d69b953878001f9e25b
FirebaseSharedSwift: d39c2ad64a11a8d936ce25a42b00df47078bb59c
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
flutter_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac flutter_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac
@ -480,9 +494,10 @@ SPEC CHECKSUMS:
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1 gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
GoogleAppMeasurement: 6e49ffac7d3f2c3ded9cc663f912a13b67bbd0de GoogleAppMeasurement: 76d4f8b36b03bd8381fa9a7fe2cc7f99c0a2e93a
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf
@ -493,7 +508,7 @@ SPEC CHECKSUMS:
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
@ -501,11 +516,12 @@ SPEC CHECKSUMS:
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990 protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqflite_darwin: a553b1fd6fe66f53bbb0fe5b4f5bab93f08d7a13
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4

View File

@ -59,6 +59,7 @@
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
showGraphicsOverview = "Yes"
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">

View File

@ -83,7 +83,6 @@
</array> </array>
<key>CFBundleLocalizations</key> <key>CFBundleLocalizations</key>
<array> <array>
<string>zh_CN</string>
<string>en</string> <string>en</string>
</array> </array>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:confetti/confetti.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -10,17 +11,25 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/account.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/realm.dart'; import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:flutter_app_update/flutter_app_update.dart'; import 'package:flutter_app_update/flutter_app_update.dart';
import 'package:version/version.dart'; import 'package:version/version.dart';
enum BootstrapperSpecialState {
userBirthday,
appAnniversary,
}
class BootstrapperShell extends StatefulWidget { class BootstrapperShell extends StatefulWidget {
final Widget child; final Widget child;
@ -41,6 +50,9 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
int _periodCursor = 0; int _periodCursor = 0;
// Special state is some special event triggered after bootstrapping
BootstrapperSpecialState? _specialState;
final Completer _bootCompleter = Completer(); final Completer _bootCompleter = Completer();
void _requestRating() async { void _requestRating() async {
@ -197,11 +209,26 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
try { try {
await Future.wait([ await Future.wait([
if (auth.isAuthorized.isTrue)
Get.find<NotificationProvider>().fetchNotification(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
Get.find<RelationshipProvider>().refreshRelativeList(), Get.find<RelationshipProvider>().refreshRelativeList(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
Get.find<RealmProvider>().refreshAvailableRealms(), Get.find<RealmProvider>().refreshAvailableRealms(),
]); ]);
if (auth.isAuthorized.isTrue && auth.userProfile.value != null) {
final account = Account.fromJson(auth.userProfile.value!);
if (account.profile?.birthday != null) {
final birthDate = account.profile!.birthday!.toLocal();
final isBirthday = birthDate.day == DateTime.now().day;
if (isBirthday) {
setState(
() => _specialState = BootstrapperSpecialState.userBirthday,
);
}
}
}
} catch (e) { } catch (e) {
context.showErrorDialog(e); context.showErrorDialog(e);
} }
@ -213,7 +240,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isTrue) { if (auth.isAuthorized.isTrue) {
try { try {
Get.find<WebSocketProvider>().registerPushNotifications(); Get.find<NotificationProvider>().registerPushNotifications();
} catch (err) { } catch (err) {
context.showSnackbar( context.showSnackbar(
'pushNotifyRegisterFailed'.trParams({'reason': err.toString()}), 'pushNotifyRegisterFailed'.trParams({'reason': err.toString()}),
@ -255,8 +282,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isBusy || _isErrored) { if (_isBusy || _isErrored) {
return GestureDetector( return GestureDetector(
child: Material( child: RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Column( child: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
@ -352,8 +378,142 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
} }
}, },
); );
} else if (_specialState != null) {
return GestureDetector(
child: RootContainer(
child: switch (_specialState) {
BootstrapperSpecialState.appAnniversary => const Placeholder(),
_ => _BirthdaySpecialScreen(),
},
),
onTap: () {
setState(() => _specialState = null);
},
);
} }
return widget.child; return widget.child;
} }
} }
class _BirthdaySpecialScreen extends StatefulWidget {
const _BirthdaySpecialScreen();
@override
State<_BirthdaySpecialScreen> createState() => _BirthdaySpecialScreenState();
}
class _BirthdaySpecialScreenState extends State<_BirthdaySpecialScreen> {
late final ConfettiController _confettiController =
ConfettiController(duration: const Duration(seconds: 10));
@override
void initState() {
_confettiController.play();
super.initState();
}
@override
void dispose() {
_confettiController.dispose();
super.dispose();
}
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
String _toOrdinal(int num) {
if (num >= 11 && num <= 13) {
return '${num}th';
}
switch (num % 10) {
case 1:
return '${num}st';
case 2:
return '${num}nd';
case 3:
return '${num}rd';
default:
return '${num}th';
}
}
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
final account = Account.fromJson(auth.userProfile.value!);
final birthDate = account.profile!.birthday!.toLocal();
final birthdayCount = DateTime.now().difference(birthDate).inDays ~/ 365;
return Stack(
children: <Widget>[
Align(
alignment: Alignment.center,
child: ConfettiWidget(
confettiController: _confettiController,
blastDirectionality: BlastDirectionality.explosive,
shouldLoop: true,
colors: const [
Colors.green,
Colors.blue,
Colors.pink,
Colors.orange,
Colors.purple
],
maxBlastForce: 30,
minBlastForce: 15,
emissionFrequency: 0.05,
numberOfParticles: 20,
gravity: 0.2,
),
),
Align(
child: CenteredContainer(
maxWidth: 320,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'🎂',
style: TextStyle(fontSize: 60),
),
const Gap(8),
Text(
'happyBirthday'.trParams({
'name': account.profile?.firstName != null
? [
account.profile?.firstName,
account.profile?.lastName
].join(' ')
: '@${account.name}',
}),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
'happyBirthdayDesc'.trParams({
'count': _toOrdinal(birthdayCount),
}),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const Gap(8),
Text(
'bsContinuable'.tr,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
).paddingOnly(bottom: 5),
],
),
),
),
],
);
}
}

View File

@ -43,14 +43,17 @@ class PostEditorController extends GetxController {
RxBool isRestoreFromLocal = false.obs; RxBool isRestoreFromLocal = false.obs;
Rx<DateTime?> lastSaveTime = Rx(null); Rx<DateTime?> lastSaveTime = Rx(null);
Timer? _saveTimer; Future? _saveFuture;
PostEditorController() { PostEditorController() {
SharedPreferences.getInstance().then((inst) { SharedPreferences.getInstance().then((inst) {
_prefs = inst; _prefs = inst;
_saveTimer = Timer.periodic( });
const Duration(seconds: 3), contentController.addListener(() {
(Timer t) { contentLength.value = contentController.text.length;
_saveFuture ??= Future.delayed(
const Duration(seconds: 1),
() {
if (isNotEmpty) { if (isNotEmpty) {
localSave(); localSave();
lastSaveTime.value = DateTime.now(); lastSaveTime.value = DateTime.now();
@ -59,12 +62,10 @@ class PostEditorController extends GetxController {
localClear(); localClear();
lastSaveTime.value = null; lastSaveTime.value = null;
} }
_saveFuture = null;
}, },
); );
}); });
contentController.addListener(() {
contentLength.value = contentController.text.length;
});
} }
Future<void> editOverview(BuildContext context) { Future<void> editOverview(BuildContext context) {
@ -124,6 +125,21 @@ class PostEditorController extends GetxController {
onRemove: (String value) { onRemove: (String value) {
attachments.remove(value); attachments.remove(value);
}, },
onInsert: (String str) {
final text = contentController.text;
final selection = contentController.selection;
final newText = text.replaceRange(
selection.start,
selection.end,
str,
);
contentController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: selection.baseOffset + str.length,
),
);
},
), ),
); );
} }
@ -355,8 +371,6 @@ class PostEditorController extends GetxController {
@override @override
void dispose() { void dispose() {
_saveTimer?.cancel();
titleController.dispose(); titleController.dispose();
descriptionController.dispose(); descriptionController.dispose();
contentController.dispose(); contentController.dispose();

View File

@ -1,9 +1,12 @@
import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/content/attachment.dart';
import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/last_read.dart'; import 'package:solian/providers/last_read.dart';
@ -31,9 +34,18 @@ class PostListController extends GetxController {
pagingController.addPageRequestListener(_onPagingControllerRequest); pagingController.addPageRequestListener(_onPagingControllerRequest);
} }
Completer<void>? _pagingLoadCompleter;
Future<void> _onPagingControllerRequest(int pageKey) async { Future<void> _onPagingControllerRequest(int pageKey) async {
try { try {
if (_pagingLoadCompleter != null) {
await _pagingLoadCompleter!.future;
return;
}
_pagingLoadCompleter = Completer();
final result = await loadMore(); final result = await loadMore();
_pagingLoadCompleter!.complete();
_pagingLoadCompleter = null;
if (result != null && hasMore.value) { if (result != null && hasMore.value) {
pagingController.appendPage(result, nextPageKey.value); pagingController.appendPage(result, nextPageKey.value);
@ -97,9 +109,6 @@ class PostListController extends GetxController {
hasMore.value = false; hasMore.value = false;
} }
final idx = <dynamic>{};
postList.retainWhere((x) => idx.add(x.id));
if (postList.isNotEmpty) { if (postList.isNotEmpty) {
var lastId = postList.map((x) => x.id).reduce(max); var lastId = postList.map((x) => x.id).reduce(max);
Get.find<LastReadProvider>().feedLastReadAt = lastId; Get.find<LastReadProvider>().feedLastReadAt = lastId;
@ -111,35 +120,39 @@ class PostListController extends GetxController {
Future<List<Post>?> _loadPosts(int pageKey) async { Future<List<Post>?> _loadPosts(int pageKey) async {
isBusy.value = true; isBusy.value = true;
final PostProvider provider = Get.find(); final PostProvider posts = Get.find();
Response resp; Response resp;
try { try {
if (author != null) { if (author != null) {
resp = await provider.listPost( resp = await posts.listPost(
pageKey, pageKey,
author: author, author: author,
take: 10,
); );
} else { } else {
switch (mode.value) { switch (mode.value) {
case 2: case 2:
resp = await provider.listRecommendations( resp = await posts.listRecommendations(
pageKey, pageKey,
channel: 'shuffle', channel: 'shuffle',
realm: realm, realm: realm,
take: 10,
); );
break; break;
case 1: case 1:
resp = await provider.listRecommendations( resp = await posts.listRecommendations(
pageKey, pageKey,
channel: 'friends', channel: 'friends',
realm: realm, realm: realm,
take: 10,
); );
break; break;
default: default:
resp = await provider.listRecommendations( resp = await posts.listRecommendations(
pageKey, pageKey,
realm: realm, realm: realm,
take: 10,
); );
break; break;
} }
@ -153,6 +166,27 @@ class PostListController extends GetxController {
final result = PaginationResult.fromJson(resp.body); final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => Post.fromJson(e)).toList(); final out = result.data?.map((e) => Post.fromJson(e)).toList();
final AttachmentProvider attach = Get.find();
if (out != null) {
final attachmentIds = out
.mapMany((x) => x.body['attachments'] ?? [])
.cast<String>()
.toSet()
.toList();
final attachmentOut = await attach.listMetadata(attachmentIds);
for (var idx = 0; idx < out.length; idx++) {
final rids = List<String>.from(out[idx].body['attachments'] ?? []);
out[idx].preload = PostPreload(
attachments: attachmentOut
.where((x) => x != null && rids.contains(x.rid))
.cast<Attachment>()
.toList(),
);
}
}
postTotal.value = result.count; postTotal.value = result.count;
return out; return out;

View File

@ -18,6 +18,7 @@ import 'package:solian/providers/database/services/messages.dart';
import 'package:solian/providers/last_read.dart'; import 'package:solian/providers/last_read.dart';
import 'package:solian/providers/link_expander.dart'; import 'package:solian/providers/link_expander.dart';
import 'package:solian/providers/navigation.dart'; import 'package:solian/providers/navigation.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/providers/stickers.dart'; import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/subscription.dart'; import 'package:solian/providers/subscription.dart';
import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/theme_switcher.dart';
@ -138,11 +139,12 @@ class SolianApp extends StatelessWidget {
Get.put(NavigationStateProvider()); Get.put(NavigationStateProvider());
Get.lazyPut(() => AuthProvider()); Get.lazyPut(() => AuthProvider());
Get.lazyPut(() => WebSocketProvider());
Get.lazyPut(() => RelationshipProvider()); Get.lazyPut(() => RelationshipProvider());
Get.lazyPut(() => PostProvider()); Get.lazyPut(() => PostProvider());
Get.lazyPut(() => StickerProvider()); Get.lazyPut(() => StickerProvider());
Get.lazyPut(() => AttachmentProvider()); Get.lazyPut(() => AttachmentProvider());
Get.lazyPut(() => WebSocketProvider()); Get.lazyPut(() => NotificationProvider());
Get.lazyPut(() => StatusProvider()); Get.lazyPut(() => StatusProvider());
Get.lazyPut(() => ChannelProvider()); Get.lazyPut(() => ChannelProvider());
Get.lazyPut(() => RealmProvider()); Get.lazyPut(() => RealmProvider());
@ -154,6 +156,6 @@ class SolianApp extends StatelessWidget {
Get.lazyPut(() => LastReadProvider()); Get.lazyPut(() => LastReadProvider());
Get.lazyPut(() => SubscriptionProvider()); Get.lazyPut(() => SubscriptionProvider());
Get.find<WebSocketProvider>().requestPermissions(); Get.find<NotificationProvider>().requestPermissions();
} }
} }

38
lib/models/audit_log.dart Normal file
View File

@ -0,0 +1,38 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
part 'audit_log.g.dart';
@JsonSerializable()
class AuditEvent {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String type;
String target;
String location;
String ipAddress;
String userAgent;
Account account;
int accountId;
AuditEvent({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.type,
required this.target,
required this.location,
required this.ipAddress,
required this.userAgent,
required this.account,
required this.accountId,
});
static AuditEvent fromJson(Map<String, dynamic> json) =>
_$AuditEventFromJson(json);
Map<String, dynamic> toJson() => _$AuditEventToJson(this);
}

View File

@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'audit_log.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuditEvent _$AuditEventFromJson(Map<String, dynamic> json) => AuditEvent(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
type: json['type'] as String,
target: json['target'] as String,
location: json['location'] as String,
ipAddress: json['ip_address'] as String,
userAgent: json['user_agent'] as String,
account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$AuditEventToJson(AuditEvent instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'target': instance.target,
'location': instance.location,
'ip_address': instance.ipAddress,
'user_agent': instance.userAgent,
'account': instance.account.toJson(),
'account_id': instance.accountId,
};

View File

@ -1,18 +1,29 @@
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
part 'notification.g.dart'; part 'notification.g.dart';
const Map<String, IconData> NotificationTopicIcons = {
'passport.security.alert': Icons.gpp_maybe,
'interactive.subscription': Icons.subscriptions,
'interactive.feedback': Icons.add_reaction,
'messaging.callStart': Icons.call_received,
};
@JsonSerializable() @JsonSerializable()
class Notification { class Notification {
int id; int id;
DateTime createdAt; DateTime createdAt;
DateTime updatedAt; DateTime updatedAt;
DateTime? deletedAt; DateTime? deletedAt;
DateTime? readAt;
String topic;
String title; String title;
String? subtitle; String? subtitle;
String body; String body;
String? avatar; String? avatar;
String? picture; String? picture;
Map<String, dynamic>? metadata;
int? senderId; int? senderId;
int accountId; int accountId;
@ -21,11 +32,14 @@ class Notification {
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
required this.deletedAt, required this.deletedAt,
required this.readAt,
required this.topic,
required this.title, required this.title,
required this.subtitle, required this.subtitle,
required this.body, required this.body,
required this.avatar, required this.avatar,
required this.picture, required this.picture,
required this.metadata,
required this.senderId, required this.senderId,
required this.accountId, required this.accountId,
}); });

View File

@ -13,11 +13,16 @@ Notification _$NotificationFromJson(Map<String, dynamic> json) => Notification(
deletedAt: json['deleted_at'] == null deletedAt: json['deleted_at'] == null
? null ? null
: DateTime.parse(json['deleted_at'] as String), : DateTime.parse(json['deleted_at'] as String),
readAt: json['read_at'] == null
? null
: DateTime.parse(json['read_at'] as String),
topic: json['topic'] as String,
title: json['title'] as String, title: json['title'] as String,
subtitle: json['subtitle'] as String?, subtitle: json['subtitle'] as String?,
body: json['body'] as String, body: json['body'] as String,
avatar: json['avatar'] as String?, avatar: json['avatar'] as String?,
picture: json['picture'] as String?, picture: json['picture'] as String?,
metadata: json['metadata'] as Map<String, dynamic>?,
senderId: (json['sender_id'] as num?)?.toInt(), senderId: (json['sender_id'] as num?)?.toInt(),
accountId: (json['account_id'] as num).toInt(), accountId: (json['account_id'] as num).toInt(),
); );
@ -28,11 +33,14 @@ Map<String, dynamic> _$NotificationToJson(Notification instance) =>
'created_at': instance.createdAt.toIso8601String(), 'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
'read_at': instance.readAt?.toIso8601String(),
'topic': instance.topic,
'title': instance.title, 'title': instance.title,
'subtitle': instance.subtitle, 'subtitle': instance.subtitle,
'body': instance.body, 'body': instance.body,
'avatar': instance.avatar, 'avatar': instance.avatar,
'picture': instance.picture, 'picture': instance.picture,
'metadata': instance.metadata,
'sender_id': instance.senderId, 'sender_id': instance.senderId,
'account_id': instance.accountId, 'account_id': instance.accountId,
}; };

View File

@ -1,10 +1,19 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/models/post_categories.dart'; import 'package:solian/models/post_categories.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
part 'post.g.dart'; part 'post.g.dart';
class PostPreload {
List<Attachment> attachments;
PostPreload({
required this.attachments,
});
}
@JsonSerializable() @JsonSerializable()
class Post { class Post {
int id; int id;
@ -15,6 +24,7 @@ class Post {
String? alias; String? alias;
String? areaAlias; String? areaAlias;
dynamic body; dynamic body;
int visibility;
List<Tag>? tags; List<Tag>? tags;
List<Category>? categories; List<Category>? categories;
List<Post>? replies; List<Post>? replies;
@ -33,6 +43,9 @@ class Post {
Account author; Account author;
PostMetric? metric; PostMetric? metric;
@JsonKey(includeFromJson: false, includeToJson: false)
PostPreload? preload;
Post({ Post({
required this.id, required this.id,
required this.createdAt, required this.createdAt,
@ -43,6 +56,7 @@ class Post {
required this.areaAlias, required this.areaAlias,
required this.type, required this.type,
required this.body, required this.body,
required this.visibility,
required this.tags, required this.tags,
required this.categories, required this.categories,
required this.replies, required this.replies,

View File

@ -20,6 +20,7 @@ Post _$PostFromJson(Map<String, dynamic> json) => Post(
areaAlias: json['area_alias'] as String?, areaAlias: json['area_alias'] as String?,
type: json['type'] as String, type: json['type'] as String,
body: json['body'], body: json['body'],
visibility: (json['visibility'] as num).toInt(),
tags: (json['tags'] as List<dynamic>?) tags: (json['tags'] as List<dynamic>?)
?.map((e) => Tag.fromJson(e as Map<String, dynamic>)) ?.map((e) => Tag.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
@ -67,6 +68,7 @@ Map<String, dynamic> _$PostToJson(Post instance) => <String, dynamic>{
'alias': instance.alias, 'alias': instance.alias,
'area_alias': instance.areaAlias, 'area_alias': instance.areaAlias,
'body': instance.body, 'body': instance.body,
'visibility': instance.visibility,
'tags': instance.tags?.map((e) => e.toJson()).toList(), 'tags': instance.tags?.map((e) => e.toJson()).toList(),
'categories': instance.categories?.map((e) => e.toJson()).toList(), 'categories': instance.categories?.map((e) => e.toJson()).toList(),
'replies': instance.replies?.map((e) => e.toJson()).toList(), 'replies': instance.replies?.map((e) => e.toJson()).toList(),

50
lib/models/theme.dart Normal file
View File

@ -0,0 +1,50 @@
import 'dart:ui';
import 'package:json_annotation/json_annotation.dart';
part 'theme.g.dart';
@JsonSerializable(converters: [ColorConverter()])
class SolianThemeData {
String id;
Color seedColor;
String? fontFamily;
List<String>? fontFamilyFallback;
SolianThemeData({
required this.id,
required this.seedColor,
this.fontFamily,
this.fontFamilyFallback,
});
factory SolianThemeData.fromJson(Map<String, dynamic> json) =>
_$SolianThemeDataFromJson(json);
Map<String, dynamic> toJson() => _$SolianThemeDataToJson(this);
@override
int get hashCode => id.hashCode;
@override
bool operator ==(Object other) {
if (other is SolianThemeData) {
return id == other.id;
}
return false;
}
}
class ColorConverter extends JsonConverter<Color, int> {
const ColorConverter();
@override
Color fromJson(int json) {
return Color(json);
}
@override
int toJson(Color object) {
return object.value;
}
}

26
lib/models/theme.g.dart Normal file
View File

@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SolianThemeData _$SolianThemeDataFromJson(Map<String, dynamic> json) =>
SolianThemeData(
id: json['id'] as String,
seedColor:
const ColorConverter().fromJson((json['seed_color'] as num).toInt()),
fontFamily: json['font_family'] as String?,
fontFamilyFallback: (json['font_family_fallback'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
Map<String, dynamic> _$SolianThemeDataToJson(SolianThemeData instance) =>
<String, dynamic>{
'id': instance.id,
'seed_color': const ColorConverter().toJson(instance.seedColor),
'font_family': instance.fontFamily,
'font_family_fallback': instance.fontFamilyFallback,
};

View File

@ -11,6 +11,7 @@ import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart'; import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/auth.dart'; import 'package:solian/models/auth.dart';
import 'package:solian/providers/database/database.dart'; import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -125,7 +126,7 @@ class AuthProvider extends GetConnect {
userAgent: await ServiceFinder.getUserAgent(), userAgent: await ServiceFinder.getUserAgent(),
sendUserAgent: true, sendUserAgent: true,
); );
client.httpClient.addAuthenticator(requestAuthenticator); client.httpClient.addRequestModifier(requestAuthenticator);
client.httpClient.baseUrl = ServiceFinder.buildUrl(service, null); client.httpClient.baseUrl = ServiceFinder.buildUrl(service, null);
return client; return client;
@ -174,7 +175,7 @@ class AuthProvider extends GetConnect {
); );
Get.find<WebSocketProvider>().connect(); Get.find<WebSocketProvider>().connect();
Get.find<WebSocketProvider>().notifyPrefetch(); Get.find<NotificationProvider>().fetchNotification();
return credentials!; return credentials!;
} }
@ -184,8 +185,8 @@ class AuthProvider extends GetConnect {
userProfile.value = null; userProfile.value = null;
Get.find<WebSocketProvider>().disconnect(); Get.find<WebSocketProvider>().disconnect();
Get.find<WebSocketProvider>().notifications.clear(); Get.find<NotificationProvider>().notifications.clear();
Get.find<WebSocketProvider>().notificationUnread.value = 0; Get.find<NotificationProvider>().notificationUnread.value = 0;
AppDatabase.removeDatabase(); AppDatabase.removeDatabase();
autoStopBackgroundNotificationService(); autoStopBackgroundNotificationService();

View File

@ -23,6 +23,21 @@ class AttachmentProvider extends GetConnect {
final Map<String, Attachment> _cachedResponses = {}; final Map<String, Attachment> _cachedResponses = {};
List<Attachment?> listMetadataFromCache(List<String> rid) {
if (rid.isEmpty) return List.empty();
List<Attachment?> result = List.filled(rid.length, null);
for (var idx = 0; idx < rid.length; idx++) {
if (_cachedResponses.containsKey(rid[idx])) {
result[idx] = _cachedResponses[rid[idx]];
} else {
result[idx] = null;
}
}
return result;
}
Future<List<Attachment?>> listMetadata( Future<List<Attachment?>> listMetadata(
List<String> rid, { List<String> rid, {
noCache = false, noCache = false,
@ -41,25 +56,27 @@ class AttachmentProvider extends GetConnect {
} }
} }
final resp = await get( if (pendingQuery.isNotEmpty) {
'/attachments?take=${pendingQuery.length}&id=${pendingQuery.join(',')}', final resp = await get(
); '/attachments?take=${pendingQuery.length}&id=${pendingQuery.join(',')}',
if (resp.statusCode != 200) return result; );
if (resp.statusCode != 200) return result;
final rawOut = PaginationResult.fromJson(resp.body); final rawOut = PaginationResult.fromJson(resp.body);
if (rawOut.data == null) return result; if (rawOut.data == null) return result;
final List<Attachment> out = final List<Attachment> out =
rawOut.data!.map((x) => Attachment.fromJson(x)).toList(); rawOut.data!.map((x) => Attachment.fromJson(x)).toList();
for (final item in out) { for (final item in out) {
if (item.destination != 0 && item.isAnalyzed) { if (item.destination != 0 && item.isAnalyzed) {
_cachedResponses[item.rid] = item; _cachedResponses[item.rid] = item;
}
} }
} for (var i = 0; i < out.length; i++) {
for (var i = 0; i < out.length; i++) { for (var j = 0; j < rid.length; j++) {
for (var j = 0; j < rid.length; j++) { if (out[i].rid == rid[j]) {
if (out[i].rid == rid[j]) { result[j] = out[i];
result[j] = out[i]; }
} }
} }
} }

View File

@ -3,22 +3,11 @@ import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart'; import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
class PostProvider extends GetConnect {
@override
void onInit() {
httpClient.baseUrl = ServiceFinder.buildUrl('interactive', null);
}
class PostProvider extends GetxController {
Future<Response> seeWhatsNew(int pivot) async { Future<Response> seeWhatsNew(int pivot) async {
GetConnect client;
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.value) { final client = await auth.configureClient('co');
client = await auth.configureClient('co');
} else {
client = await ServiceFinder.configureClient('co');
}
final resp = await client.get('/whats-new?pivot=$pivot'); final resp = await client.get('/whats-new?pivot=$pivot');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
@ -28,19 +17,14 @@ class PostProvider extends GetConnect {
} }
Future<Response> listRecommendations(int page, Future<Response> listRecommendations(int page,
{String? realm, String? channel}) async { {String? realm, String? channel, int take = 10}) async {
GetConnect client;
final AuthProvider auth = Get.find();
final queries = [ final queries = [
'take=${10}', 'take=$take',
'offset=$page', 'offset=$page',
if (realm != null) 'realm=$realm', if (realm != null) 'realm=$realm',
]; ];
if (auth.isAuthorized.value) { final AuthProvider auth = Get.find();
client = await auth.configureClient('co'); final client = await auth.configureClient('interactive');
} else {
client = await ServiceFinder.configureClient('co');
}
final resp = await client.get( final resp = await client.get(
channel == null channel == null
? '/recommendations?${queries.join('&')}' ? '/recommendations?${queries.join('&')}'
@ -60,9 +44,33 @@ class PostProvider extends GetConnect {
final queries = [ final queries = [
'take=${10}', 'take=${10}',
'offset=$page', 'offset=$page',
'truncate=false',
]; ];
final client = await auth.configureClient('interactive'); final client = await auth.configureClient('interactive');
final resp = await client.get('/posts/drafts?${queries.join('&')}'); final resp = await client.get(
'/posts/drafts?${queries.join('&')}',
);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return resp;
}
Future<Response> searchPost(String probe, int page,
{String? realm, String? author, tag, category, int take = 10}) async {
final queries = [
'probe=$probe',
'take=$take',
'offset=$page',
if (tag != null) 'tag=$tag',
if (category != null) 'category=$category',
if (author != null) 'author=$author',
if (realm != null) 'realm=$realm',
];
final AuthProvider auth = Get.find();
final client = await auth.configureClient('co');
final resp = await client.get('/posts/search?${queries.join('&')}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
@ -71,16 +79,18 @@ class PostProvider extends GetConnect {
} }
Future<Response> listPost(int page, Future<Response> listPost(int page,
{String? realm, String? author, tag, category}) async { {String? realm, String? author, tag, category, int take = 10}) async {
final queries = [ final queries = [
'take=${10}', 'take=$take',
'offset=$page', 'offset=$page',
if (tag != null) 'tag=$tag', if (tag != null) 'tag=$tag',
if (category != null) 'category=$category', if (category != null) 'category=$category',
if (author != null) 'author=$author', if (author != null) 'author=$author',
if (realm != null) 'realm=$realm', if (realm != null) 'realm=$realm',
]; ];
final resp = await get('/posts?${queries.join('&')}'); final AuthProvider auth = Get.find();
final client = await auth.configureClient('co');
final resp = await client.get('/posts?${queries.join('&')}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
@ -89,7 +99,10 @@ class PostProvider extends GetConnect {
} }
Future<Response> listPostReplies(String alias, int page) async { Future<Response> listPostReplies(String alias, int page) async {
final resp = await get('/posts/$alias/replies?take=${10}&offset=$page'); final AuthProvider auth = Get.find();
final client = await auth.configureClient('co');
final resp =
await client.get('/posts/$alias/replies?take=${10}&offset=$page');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
@ -98,7 +111,9 @@ class PostProvider extends GetConnect {
} }
Future<List<Post>> listPostFeaturedReply(String alias, {int take = 1}) async { Future<List<Post>> listPostFeaturedReply(String alias, {int take = 1}) async {
final resp = await get('/posts/$alias/replies/featured?take=$take'); final AuthProvider auth = Get.find();
final client = await auth.configureClient('co');
final resp = await client.get('/posts/$alias/replies/featured?take=$take');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
@ -107,16 +122,9 @@ class PostProvider extends GetConnect {
} }
Future<Response> getPost(String alias) async { Future<Response> getPost(String alias) async {
final resp = await get('/posts/$alias'); final AuthProvider auth = Get.find();
if (resp.statusCode != 200) { final client = await auth.configureClient('co');
throw RequestException(resp); final resp = await client.get('/posts/$alias');
}
return resp;
}
Future<Response> getArticle(String alias) async {
final resp = await get('/articles/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }

View File

@ -299,53 +299,71 @@ typedef $$LocalMessageEventTableTableUpdateCompanionBuilder
}); });
class $$LocalMessageEventTableTableFilterComposer class $$LocalMessageEventTableTableFilterComposer
extends FilterComposer<_$AppDatabase, $LocalMessageEventTableTable> { extends Composer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableFilterComposer(super.$state); $$LocalMessageEventTableTableFilterComposer({
ColumnFilters<int> get id => $state.composableBuilder( required super.$db,
column: $state.table.id, required super.$table,
builder: (column, joinBuilders) => super.joinBuilder,
ColumnFilters(column, joinBuilders: joinBuilders)); super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<int> get channelId => $state.composableBuilder( ColumnFilters<int> get channelId => $composableBuilder(
column: $state.table.channelId, column: $table.channelId, builder: (column) => ColumnFilters(column));
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<Event?, Event, String> get data => ColumnWithTypeConverterFilters<Event?, Event, String> get data =>
$state.composableBuilder( $composableBuilder(
column: $state.table.data, column: $table.data,
builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( builder: (column) => ColumnWithTypeConverterFilters(column));
column,
joinBuilders: joinBuilders));
ColumnFilters<DateTime> get createdAt => $state.composableBuilder( ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $state.table.createdAt, column: $table.createdAt, builder: (column) => ColumnFilters(column));
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
} }
class $$LocalMessageEventTableTableOrderingComposer class $$LocalMessageEventTableTableOrderingComposer
extends OrderingComposer<_$AppDatabase, $LocalMessageEventTableTable> { extends Composer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableOrderingComposer(super.$state); $$LocalMessageEventTableTableOrderingComposer({
ColumnOrderings<int> get id => $state.composableBuilder( required super.$db,
column: $state.table.id, required super.$table,
builder: (column, joinBuilders) => super.joinBuilder,
ColumnOrderings(column, joinBuilders: joinBuilders)); super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<int> get channelId => $state.composableBuilder( ColumnOrderings<int> get channelId => $composableBuilder(
column: $state.table.channelId, column: $table.channelId, builder: (column) => ColumnOrderings(column));
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get data => $state.composableBuilder( ColumnOrderings<String> get data => $composableBuilder(
column: $state.table.data, column: $table.data, builder: (column) => ColumnOrderings(column));
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<DateTime> get createdAt => $state.composableBuilder( ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $state.table.createdAt, column: $table.createdAt, builder: (column) => ColumnOrderings(column));
builder: (column, joinBuilders) => }
ColumnOrderings(column, joinBuilders: joinBuilders));
class $$LocalMessageEventTableTableAnnotationComposer
extends Composer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<int> get channelId =>
$composableBuilder(column: $table.channelId, builder: (column) => column);
GeneratedColumnWithTypeConverter<Event?, String> get data =>
$composableBuilder(column: $table.data, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
} }
class $$LocalMessageEventTableTableTableManager extends RootTableManager< class $$LocalMessageEventTableTableTableManager extends RootTableManager<
@ -354,6 +372,7 @@ class $$LocalMessageEventTableTableTableManager extends RootTableManager<
LocalMessageEventTableData, LocalMessageEventTableData,
$$LocalMessageEventTableTableFilterComposer, $$LocalMessageEventTableTableFilterComposer,
$$LocalMessageEventTableTableOrderingComposer, $$LocalMessageEventTableTableOrderingComposer,
$$LocalMessageEventTableTableAnnotationComposer,
$$LocalMessageEventTableTableCreateCompanionBuilder, $$LocalMessageEventTableTableCreateCompanionBuilder,
$$LocalMessageEventTableTableUpdateCompanionBuilder, $$LocalMessageEventTableTableUpdateCompanionBuilder,
( (
@ -368,10 +387,15 @@ class $$LocalMessageEventTableTableTableManager extends RootTableManager<
: super(TableManagerState( : super(TableManagerState(
db: db, db: db,
table: table, table: table,
filteringComposer: $$LocalMessageEventTableTableFilterComposer( createFilteringComposer: () =>
ComposerState(db, table)), $$LocalMessageEventTableTableFilterComposer(
orderingComposer: $$LocalMessageEventTableTableOrderingComposer( $db: db, $table: table),
ComposerState(db, table)), createOrderingComposer: () =>
$$LocalMessageEventTableTableOrderingComposer(
$db: db, $table: table),
createComputedFieldComposer: () =>
$$LocalMessageEventTableTableAnnotationComposer(
$db: db, $table: table),
updateCompanionCallback: ({ updateCompanionCallback: ({
Value<int> id = const Value.absent(), Value<int> id = const Value.absent(),
Value<int> channelId = const Value.absent(), Value<int> channelId = const Value.absent(),
@ -410,6 +434,7 @@ typedef $$LocalMessageEventTableTableProcessedTableManager
LocalMessageEventTableData, LocalMessageEventTableData,
$$LocalMessageEventTableTableFilterComposer, $$LocalMessageEventTableTableFilterComposer,
$$LocalMessageEventTableTableOrderingComposer, $$LocalMessageEventTableTableOrderingComposer,
$$LocalMessageEventTableTableAnnotationComposer,
$$LocalMessageEventTableTableCreateCompanionBuilder, $$LocalMessageEventTableTableCreateCompanionBuilder,
$$LocalMessageEventTableTableUpdateCompanionBuilder, $$LocalMessageEventTableTableUpdateCompanionBuilder,
( (

View File

@ -4,19 +4,19 @@ import 'package:intl/intl.dart';
class ExperienceProvider extends GetxController { class ExperienceProvider extends GetxController {
static List<int> experienceToLevelRequirements = [ static List<int> experienceToLevelRequirements = [
0, // Level 0 0, // Level 0
100, // Level 1 1000, // Level 1
400, // Level 2 4000, // Level 2
900, // Level 3 9000, // Level 3
1600, // Level 4 16000, // Level 4
2500, // Level 5 25000, // Level 5
3600, // Level 6 36000, // Level 6
4900, // Level 7 49000, // Level 7
6400, // Level 8 64000, // Level 8
8100, // Level 9 81000, // Level 9
10000, // Level 10 100000, // Level 10
12100, // Level 11 121000, // Level 11
14400, // Level 12 144000, // Level 12
36800 // Level 13 368000 // Level 13
]; ];
static List<String> levelLabelMapping = static List<String> levelLabelMapping =
@ -35,7 +35,7 @@ class ExperienceProvider extends GetxController {
final idx = experienceToLevelRequirements.indexOf(exp); final idx = experienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= experienceToLevelRequirements.length) return 1; if (idx + 1 >= experienceToLevelRequirements.length) return 1;
final nextExp = experienceToLevelRequirements[idx + 1]; final nextExp = experienceToLevelRequirements[idx + 1];
return exp / nextExp; return (experience - exp).abs() / (exp - nextExp).abs();
} }
static String calcLevelUpProgressLevel(int experience) { static String calcLevelUpProgressLevel(int experience) {
@ -43,9 +43,9 @@ class ExperienceProvider extends GetxController {
.firstWhere((x) => x <= experience); .firstWhere((x) => x <= experience);
final idx = experienceToLevelRequirements.indexOf(exp); final idx = experienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= experienceToLevelRequirements.length) return 'Infinity'; if (idx + 1 >= experienceToLevelRequirements.length) return 'Infinity';
final nextExp = experienceToLevelRequirements[idx + 1]; final nextExp = exp - experienceToLevelRequirements[idx + 1];
final formatter = final formatter =
NumberFormat.compactCurrency(symbol: '', decimalDigits: 1); NumberFormat.compactCurrency(symbol: '', decimalDigits: 1);
return '${formatter.format(exp)}/${formatter.format(nextExp)}'; return '${formatter.format((exp - experience).abs())}/${formatter.format(nextExp.abs())}';
} }
} }

View File

@ -0,0 +1,175 @@
import 'dart:developer';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/notification.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart';
class NotificationProvider extends GetxController {
RxBool isBusy = false.obs;
RxInt notificationUnread = 0.obs;
RxList<Notification> notifications =
List<Notification>.empty(growable: true).obs;
Future<void> fetchNotification() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final client = await auth.configureClient('auth');
final resp = await client.get('/notifications?skip=0&take=100');
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final data = result.data?.map((x) => Notification.fromJson(x)).toList();
if (data != null) {
notifications.addAll(data);
notificationUnread.value = data.where((x) => x.readAt == null).length;
}
}
}
Future<void> markAllRead() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
isBusy.value = true;
final NotificationProvider nty = Get.find();
List<int> markList = List.empty(growable: true);
for (final element in nty.notifications) {
if (element.id <= 0) continue;
if (element.readAt != null) continue;
markList.add(element.id);
}
if (markList.isNotEmpty) {
final client = await auth.configureClient('auth');
await client.put('/notifications/read', {'messages': markList});
}
nty.notifications.value = nty.notifications.map((x) {
x.readAt = DateTime.now();
return x;
}).toList();
nty.notifications.refresh();
isBusy.value = false;
}
Future<void> markOneRead(Notification element, int index) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final NotificationProvider nty = Get.find();
if (element.id <= 0) {
nty.notifications.removeAt(index);
return;
} else if (element.readAt != null) {
return;
}
isBusy.value = true;
final client = await auth.configureClient('auth');
await client.put('/notifications/read/${element.id}', {});
nty.notifications[0].readAt = DateTime.now();
nty.notifications.refresh();
isBusy.value = false;
}
void requestPermissions() {
try {
FirebaseMessaging.instance.requestPermission(
alert: true,
announcement: true,
carPlay: true,
badge: true,
sound: true);
} catch (_) {
// When firebase isn't initialized (background service)
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
}
}
Future<void> registerPushNotifications() async {
if (PlatformInfo.isWeb) return;
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('service_background_notification') == true) {
log('Background notification service has been enabled, skip register push notifications');
return;
}
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
late final String? token;
late final String provider;
var deviceUuid = await _getDeviceUuid();
if (deviceUuid == null || deviceUuid.isEmpty) {
log("Unable to active push notifications, couldn't get device uuid");
return;
} else {
log('Device UUID is $deviceUuid');
}
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
provider = 'apple';
token = await FirebaseMessaging.instance.getAPNSToken();
} else {
provider = 'firebase';
token = await FirebaseMessaging.instance.getToken();
}
log('Device Push Token is $token');
final client = await auth.configureClient('auth');
final resp = await client.post('/notifications/subscribe', {
'provider': provider,
'device_token': token,
'device_id': deviceUuid,
});
if (resp.statusCode != 200 && resp.statusCode != 400) {
throw RequestException(resp);
}
}
Future<String?> _getDeviceUuid() async {
if (PlatformInfo.isWeb) return null;
return await FlutterUdid.consistentUdid;
}
}

View File

@ -1,5 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/models/theme.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
class ThemeSwitcher extends ChangeNotifier { class ThemeSwitcher extends ChangeNotifier {
@ -13,11 +16,21 @@ class ThemeSwitcher extends ChangeNotifier {
Future<void> restoreTheme() async { Future<void> restoreTheme() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('global_theme_color')) { if (prefs.containsKey('global_theme')) {
final value = prefs.getInt('global_theme_color')!; final value = SolianThemeData.fromJson(
final color = Color(value); jsonDecode(prefs.getString('global_theme')!),
lightThemeData = AppTheme.build(Brightness.light, seedColor: color); );
darkThemeData = AppTheme.build(Brightness.dark, seedColor: color); final agedTheme = prefs.getBool('aged_theme');
lightThemeData = AppTheme.buildFromData(
Brightness.light,
value,
useMaterial3: agedTheme == null ? true : !agedTheme,
);
darkThemeData = AppTheme.buildFromData(
Brightness.dark,
value,
useMaterial3: agedTheme == null ? true : !agedTheme,
);
notifyListeners(); notifyListeners();
} }
} }
@ -27,4 +40,25 @@ class ThemeSwitcher extends ChangeNotifier {
darkThemeData = dark; darkThemeData = dark;
notifyListeners(); notifyListeners();
} }
Future<void> setThemeData(SolianThemeData? data) async {
final prefs = await SharedPreferences.getInstance();
if (data == null) {
prefs.remove('global_theme');
} else {
prefs.setString(
'global_theme',
jsonEncode(data.toJson()),
);
lightThemeData = AppTheme.buildFromData(Brightness.light, data);
darkThemeData = AppTheme.buildFromData(Brightness.dark, data);
notifyListeners();
}
}
Future<void> setAgedTheme(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
prefs.setBool('aged_theme', enabled);
await restoreTheme();
}
} }

View File

@ -3,17 +3,11 @@ import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/notification.dart'; import 'package:solian/models/notification.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
@ -21,56 +15,10 @@ class WebSocketProvider extends GetxController {
RxBool isConnected = false.obs; RxBool isConnected = false.obs;
RxBool isConnecting = false.obs; RxBool isConnecting = false.obs;
RxInt notificationUnread = 0.obs;
RxList<Notification> notifications =
List<Notification>.empty(growable: true).obs;
WebSocketChannel? websocket; WebSocketChannel? websocket;
StreamController<NetworkPackage> stream = StreamController.broadcast(); StreamController<NetworkPackage> stream = StreamController.broadcast();
@override
onInit() {
notifyPrefetch();
super.onInit();
}
void requestPermissions() {
try {
FirebaseMessaging.instance.requestPermission(
alert: true,
announcement: true,
carPlay: true,
badge: true,
sound: true);
} catch (_) {
// When firebase isn't initialized (background service)
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
}
}
Future<void> connect({noRetry = false}) async { Future<void> connect({noRetry = false}) async {
if (isConnected.value) { if (isConnected.value) {
return; return;
@ -119,8 +67,9 @@ class WebSocketProvider extends GetxController {
log('Websocket incoming message: ${packet.method} ${packet.message}'); log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet); stream.sink.add(packet);
if (packet.method == 'notifications.new') { if (packet.method == 'notifications.new') {
notifications.add(Notification.fromJson(packet.payload!)); final NotificationProvider nty = Get.find();
notificationUnread.value++; nty.notifications.add(Notification.fromJson(packet.payload!));
nty.notificationUnread.value++;
} }
}, },
onDone: () { onDone: () {
@ -133,95 +82,4 @@ class WebSocketProvider extends GetxController {
}, },
); );
} }
Future<void> notifyPrefetch() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final client = await auth.configureClient('auth');
final resp = await client.get('/notifications?skip=0&take=100');
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final data = result.data?.map((x) => Notification.fromJson(x)).toList();
if (data != null) {
notifications.addAll(data);
notificationUnread.value = data.length;
}
}
}
Future<void> registerPushNotifications() async {
if (PlatformInfo.isWeb) return;
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('service_background_notification') == true) {
log('Background notification service has been enabled, skip register push notifications');
return;
}
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
late final String? token;
late final String provider;
final deviceUuid = await _getDeviceUuid();
if (deviceUuid == null || deviceUuid.isEmpty) {
log("Unable to active push notifications, couldn't get device uuid");
} else {
log('Device UUID is $deviceUuid');
}
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
provider = 'apple';
token = await FirebaseMessaging.instance.getAPNSToken();
} else {
provider = 'firebase';
token = await FirebaseMessaging.instance.getToken();
}
log('Device Push Token is $token');
final client = await auth.configureClient('auth');
final resp = await client.post('/notifications/subscribe', {
'provider': provider,
'device_token': token,
'device_id': deviceUuid,
});
if (resp.statusCode != 200 && resp.statusCode != 400) {
throw RequestException(resp);
}
}
Future<String?> _getDeviceUuid() async {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (PlatformInfo.isWeb) {
final webInfo = await deviceInfo.webBrowserInfo;
return webInfo.vendor! +
webInfo.userAgent! +
webInfo.hardwareConcurrency.toString();
}
if (PlatformInfo.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
return androidInfo.id;
}
if (PlatformInfo.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
return iosInfo.identifierForVendor!;
}
if (PlatformInfo.isLinux) {
final linuxInfo = await deviceInfo.linuxInfo;
return linuxInfo.machineId!;
}
if (PlatformInfo.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
return windowsInfo.deviceId;
}
if (PlatformInfo.isMacOS) {
final macosInfo = await deviceInfo.macOsInfo;
return macosInfo.systemGUID;
}
return null;
}
} }

View File

@ -2,11 +2,14 @@ import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:solian/bootstrapper.dart'; import 'package:solian/bootstrapper.dart';
import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/screens/about.dart'; import 'package:solian/screens/about.dart';
import 'package:solian/screens/account.dart'; import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/audit_log.dart';
import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/preferences/notifications.dart'; import 'package:solian/screens/account/preferences/notifications.dart';
import 'package:solian/screens/account/preferences/security.dart';
import 'package:solian/screens/account/profile_edit.dart'; import 'package:solian/screens/account/profile_edit.dart';
import 'package:solian/screens/account/profile_page.dart'; import 'package:solian/screens/account/profile_page.dart';
import 'package:solian/screens/auth/signin.dart'; import 'package:solian/screens/auth/signin.dart';
@ -16,9 +19,9 @@ import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart'; import 'package:solian/screens/channel/channel_organize.dart';
import 'package:solian/screens/chat.dart'; import 'package:solian/screens/chat.dart';
import 'package:solian/screens/dashboard.dart'; import 'package:solian/screens/dashboard.dart';
import 'package:solian/screens/feed/search.dart'; import 'package:solian/screens/posts/post_search.dart';
import 'package:solian/screens/posts/post_detail.dart'; import 'package:solian/screens/posts/post_detail.dart';
import 'package:solian/screens/feed/draft_box.dart'; import 'package:solian/screens/posts/draft_box.dart';
import 'package:solian/screens/realms.dart'; import 'package:solian/screens/realms.dart';
import 'package:solian/screens/realms/realm_detail.dart'; import 'package:solian/screens/realms/realm_detail.dart';
import 'package:solian/screens/realms/realm_organize.dart'; import 'package:solian/screens/realms/realm_organize.dart';
@ -94,7 +97,7 @@ abstract class AppRouter {
name: 'postSearch', name: 'postSearch',
builder: (context, state) => TitleShell( builder: (context, state) => TitleShell(
state: state, state: state,
child: FeedSearchScreen( child: PostSearchScreen(
tag: state.uri.queryParameters['tag'], tag: state.uri.queryParameters['tag'],
category: state.uri.queryParameters['category'], category: state.uri.queryParameters['category'],
), ),
@ -107,6 +110,7 @@ abstract class AppRouter {
state: state, state: state,
child: PostDetailScreen( child: PostDetailScreen(
id: state.pathParameters['id']!, id: state.pathParameters['id']!,
post: state.extra as Post?,
), ),
), ),
), ),
@ -178,6 +182,7 @@ abstract class AppRouter {
final arguments = state.extra as ChannelDetailArguments; final arguments = state.extra as ChannelDetailArguments;
return TitleShell( return TitleShell(
state: state, state: state,
isResponsive: true,
child: ChannelDetailScreen( child: ChannelDetailScreen(
channel: arguments.channel, channel: arguments.channel,
profile: arguments.profile, profile: arguments.profile,
@ -263,6 +268,22 @@ abstract class AppRouter {
child: const NotificationPreferencesScreen(), child: const NotificationPreferencesScreen(),
), ),
), ),
GoRoute(
path: '/account/preferences/auth',
name: 'authPreferences',
builder: (context, state) => TitleShell(
state: state,
child: const AuthPreferencesScreen(),
),
),
GoRoute(
path: '/account/audit',
name: 'auditLog',
builder: (context, state) => TitleShell(
state: state,
child: const AuditLogScreen(),
),
),
GoRoute( GoRoute(
path: '/account/view/:name', path: '/account/view/:name',
name: 'accountProfilePage', name: 'accountProfilePage',

View File

@ -15,133 +15,130 @@ class AboutScreen extends StatelessWidget {
const denseButtonStyle = const denseButtonStyle =
ButtonStyle(visualDensity: VisualDensity(vertical: -4)); ButtonStyle(visualDensity: VisualDensity(vertical: -4));
return Material( return SizedBox(
color: Theme.of(context).colorScheme.surface, width: double.infinity,
child: SizedBox( child: Column(
width: double.infinity, mainAxisAlignment: MainAxisAlignment.center,
child: Column( children: [
mainAxisAlignment: MainAxisAlignment.center, ClipRRect(
children: [ borderRadius: const BorderRadius.all(Radius.circular(16)),
ClipRRect( child: Image.asset('assets/logo.png', width: 120, height: 120),
borderRadius: const BorderRadius.all(Radius.circular(16)), ),
child: Image.asset('assets/logo.png', width: 120, height: 120), const Gap(8),
), Text(
const Gap(8), 'Solian',
Text( style: Theme.of(context).textTheme.headlineMedium,
'Solian', ),
style: Theme.of(context).textTheme.headlineMedium, const Text(
), 'The Solar Network',
const Text( style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
'The Solar Network', ),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), const Gap(8),
), FutureBuilder(
const Gap(8), future: PackageInfo.fromPlatform(),
FutureBuilder( builder: (context, snapshot) {
future: PackageInfo.fromPlatform(), if (!snapshot.hasData) {
builder: (context, snapshot) { return const SizedBox.shrink();
if (!snapshot.hasData) { }
return const SizedBox.shrink();
}
return Text( return Text(
'v${snapshot.data!.version} · ${snapshot.data!.buildNumber}', 'v${snapshot.data!.version} · ${snapshot.data!.buildNumber}',
style: const TextStyle(fontFamily: 'monospace'), style: const TextStyle(fontFamily: 'monospace'),
); );
}, },
), ),
Text('Copyright © ${DateTime.now().year} Solsynth LLC'), Text('Copyright © ${DateTime.now().year} Solsynth LLC'),
const Gap(16), const Gap(16),
CenteredContainer( CenteredContainer(
maxWidth: 280, maxWidth: 280,
child: Wrap( child: Wrap(
spacing: 4, spacing: 4,
runSpacing: 4, runSpacing: 4,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: [ children: [
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
child: Text('appDetails'.tr), child: Text('appDetails'.tr),
onPressed: () async { onPressed: () async {
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationVersion: applicationVersion:
'${info.version} (${info.buildNumber})', '${info.version} (${info.buildNumber})',
applicationLegalese: applicationLegalese:
'The Solar Network App is an intuitive and open-source social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.', 'The Solar Network App is an intuitive and open-source social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.',
applicationIcon: ClipRRect( applicationIcon: ClipRRect(
borderRadius: borderRadius:
const BorderRadius.all(Radius.circular(16)), const BorderRadius.all(Radius.circular(16)),
child: Image.asset('assets/logo.png', child: Image.asset('assets/logo.png',
width: 60, height: 60), width: 60, height: 60),
), ),
); );
}, },
), ),
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
child: Text('projectWebsite'.tr), child: Text('projectWebsite'.tr),
onPressed: () { onPressed: () {
launchUrlString( launchUrlString(
'https://solsynth.dev/products/solar-network'); 'https://solsynth.dev/products/solar-network');
}, },
), ),
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
child: Text('termRelated'.tr), child: Text('termRelated'.tr),
onPressed: () { onPressed: () {
launchUrlString('https://solsynth.dev/terms'); launchUrlString('https://solsynth.dev/terms');
}, },
), ),
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
child: Text('serviceStatus'.tr), child: Text('serviceStatus'.tr),
onPressed: () { onPressed: () {
launchUrlString('https://status.solsynth.dev'); launchUrlString('https://status.solsynth.dev');
}, },
), ),
], ],
),
), ),
const Gap(16), ),
const Text( const Gap(16),
'Open-sourced under AGPLv3', const Text(
style: TextStyle( 'Open-sourced under AGPLv3',
style: TextStyle(
fontWeight: FontWeight.w300,
fontSize: 12,
),
),
FutureBuilder(
future: SharedPreferences.getInstance(),
builder: (context, snapshot) {
const textStyle = TextStyle(
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
fontSize: 12, fontSize: 12,
), );
), if (!snapshot.hasData ||
FutureBuilder( !snapshot.data!.containsKey('first_boot_time')) {
future: SharedPreferences.getInstance(), return Text(
builder: (context, snapshot) { 'firstBootTime'.trParams({'time': 'unknown'.tr}),
const textStyle = TextStyle( style: textStyle,
fontWeight: FontWeight.w300,
fontSize: 12,
); );
if (!snapshot.hasData || } else {
!snapshot.data!.containsKey('first_boot_time')) { return Text(
return Text( 'firstBootTime'.trParams({
'firstBootTime'.trParams({'time': 'unknown'.tr}), 'time': DateFormat('yyyy-MM-dd').format(
style: textStyle, DateTime.tryParse(
); snapshot.data!.getString('first_boot_time')!,
} else { )?.toLocal() ??
return Text( DateTime.now(),
'firstBootTime'.trParams({ ),
'time': DateFormat('yyyy-MM-dd').format( }),
DateTime.tryParse( style: textStyle,
snapshot.data!.getString('first_boot_time')!, );
)?.toLocal() ?? }
DateTime.now(), },
), ),
}), ],
style: textStyle,
);
}
},
),
],
),
), ),
); );
} }

View File

@ -49,113 +49,128 @@ class _AccountScreenState extends State<AccountScreen> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
return Material( return SafeArea(
color: Theme.of(context).colorScheme.surface, child: Obx(() {
child: SafeArea( if (auth.isAuthorized.isFalse) {
child: Obx(() { return Center(
if (auth.isAuthorized.isFalse) { child: Column(
return Center( mainAxisSize: MainAxisSize.min,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ActionCard(
icon: Icon(
Icons.login,
color: Theme.of(context).colorScheme.onPrimary,
),
title: 'signin'.tr,
caption: 'signinCaption'.tr,
onTap: () {
AppRouter.instance.pushNamed('signin').then((val) async {
if (val == true) {
await auth.refreshUserProfile();
}
});
},
),
_ActionCard(
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.onPrimary,
),
title: 'signup'.tr,
caption: 'signupCaption'.tr,
onTap: () {
AppRouter.instance.pushNamed('signup').then((_) {
setState(() {});
});
},
),
const Gap(4),
TextButton(
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -2,
),
),
onPressed: () {
AppRouter.instance.pushNamed('settings');
},
child: Text('settings'.tr),
),
],
),
);
}
return CenteredContainer(
child: ListView(
children: [ children: [
if (auth.userProfile.value != null) _ActionCard(
const AccountHeading().paddingOnly(bottom: 8, top: 8), icon: Icon(
...(actionItems.map( Icons.login,
(x) => ListTile( color: Theme.of(context).colorScheme.onPrimary,
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: x.$1,
title: Text(x.$2),
onTap: () {
AppRouter.instance
.pushNamed(x.$3)
.then((_) => setState(() {}));
},
), ),
)), title: 'signin'.tr,
const Divider(thickness: 0.3, height: 1) caption: 'signinCaption'.tr,
.paddingSymmetric(vertical: 4),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.settings),
title: Text('settings'.tr),
onTap: () { onTap: () {
AppRouter.instance.pushNamed('settings'); AppRouter.instance.pushNamed('signin').then((val) async {
if (val == true) {
await auth.refreshUserProfile();
}
});
}, },
), ),
if (auth.isAuthorized.value) _ActionCard(
ListTile( icon: Icon(
contentPadding: const EdgeInsets.symmetric(horizontal: 34), Icons.add,
leading: const Icon(Icons.edit_notifications), color: Theme.of(context).colorScheme.onPrimary,
title: Text('notificationPreferences'.tr),
onTap: () {
AppRouter.instance.pushNamed('notificationPreferences');
},
), ),
const Divider(thickness: 0.3, height: 1) title: 'signup'.tr,
.paddingSymmetric(vertical: 4), caption: 'signupCaption'.tr,
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout),
title: Text('signout'.tr),
onTap: () { onTap: () {
auth.signout(); AppRouter.instance.pushNamed('signup').then((_) {
setState(() {}); setState(() {});
});
}, },
), ),
const Gap(4),
TextButton(
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -2,
),
),
onPressed: () {
AppRouter.instance.pushNamed('settings');
},
child: Text('settings'.tr),
),
], ],
), ),
); );
}), }
),
return CenteredContainer(
child: ListView(
children: [
if (auth.userProfile.value != null)
const AccountHeading().paddingOnly(bottom: 8, top: 16),
...(actionItems.map(
(x) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: x.$1,
title: Text(x.$2),
onTap: () {
AppRouter.instance
.pushNamed(x.$3)
.then((_) => setState(() {}));
},
),
)),
const Divider(thickness: 0.3, height: 1)
.paddingSymmetric(vertical: 4),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.settings),
title: Text('settings'.tr),
onTap: () {
AppRouter.instance.pushNamed('settings');
},
),
if (auth.isAuthorized.value)
ListTile(
leading: const Icon(Icons.event_repeat),
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
title: Text('auditLog'.tr),
onTap: () {
AppRouter.instance.pushNamed('auditLog');
},
),
if (auth.isAuthorized.value)
ListTile(
leading: const Icon(Icons.lock),
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
title: Text('authPreferences'.tr),
onTap: () {
AppRouter.instance.pushNamed('authPreferences');
},
),
if (auth.isAuthorized.value)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.edit_notifications),
title: Text('notificationPreferences'.tr),
onTap: () {
AppRouter.instance.pushNamed('notificationPreferences');
},
),
const Divider(thickness: 0.3, height: 1)
.paddingSymmetric(vertical: 4),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout),
title: Text('signout'.tr),
onTap: () {
auth.signout();
setState(() {});
},
),
],
),
);
}),
); );
} }
} }

View File

@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:marquee/marquee.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/audit_log.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/relative_date.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import 'package:timeline_tile/timeline_tile.dart';
class AuditLogScreen extends StatefulWidget {
const AuditLogScreen({super.key});
@override
State<AuditLogScreen> createState() => _AuditLogScreenState();
}
class _AuditLogScreenState extends State<AuditLogScreen> {
bool _isBusy = true;
final List<AuditEvent> _events = List.empty(growable: true);
Future<void> _getEvents() async {
if (!_isBusy) setState(() => _isBusy = true);
final AuthProvider auth = Get.find();
final client = await auth.configureClient('id');
final resp =
await client.get('/users/me/events?take=15&offset=${_events.length}');
if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp));
}
final result = PaginationResult.fromJson(resp.body);
setState(() {
_events.addAll(
result.data?.map((x) => AuditEvent.fromJson(x)).toList() ??
List.empty(),
);
_isBusy = false;
});
}
bool _showIp = false;
String _censorIpAddress(String ip) {
List<String> parts = ip.split('.');
if (parts.length == 4) {
String censoredPart1 = '*' * parts[1].length;
String censoredPart2 = '*' * parts[2].length;
String censoredPart3 = '*' * parts[3].length;
return '${parts[0]}.$censoredPart1.$censoredPart2.$censoredPart3';
} else {
return '***.***.***.***';
}
}
@override
void initState() {
super.initState();
_getEvents();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
CheckboxListTile(
value: _showIp,
title: Text('showIp'.tr),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: const Icon(Icons.alternate_email),
tileColor:
Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.5),
onChanged: (val) {
setState(() => _showIp = val ?? false);
},
),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_events.clear();
return _getEvents();
},
child: InfiniteList(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: _events.length,
isLoading: _isBusy,
onFetchData: () {
_getEvents();
},
itemBuilder: (context, idx) {
final element = _events[idx];
return TimelineTile(
isFirst: idx == 0,
isLast: _events.length - 1 == idx,
alignment: TimelineAlign.start,
indicatorStyle: IndicatorStyle(width: 15),
endChild: Container(
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
element.type,
style: GoogleFonts.robotoMono(fontSize: 15),
),
Text(
_showIp
? element.ipAddress
: _censorIpAddress(element.ipAddress),
style: GoogleFonts.sourceCodePro(
fontWeight: FontWeight.bold,
),
),
SizedBox(
height: 20,
width: double.maxFinite,
child: Marquee(
text: element.userAgent,
velocity: 25,
startAfter: Duration(milliseconds: 500),
pauseAfterRound: Duration(milliseconds: 3000),
),
),
Row(
children: [
RelativeDate(element.createdAt),
const Gap(6),
Text('·'),
const Gap(6),
RelativeDate(element.createdAt, isFull: true),
],
),
],
).paddingSymmetric(horizontal: 12, vertical: 8),
).paddingOnly(left: 16),
),
).paddingSymmetric(horizontal: 18);
},
),
),
),
],
);
}
}

View File

@ -6,6 +6,7 @@ import 'package:solian/models/relations.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/account/relative_list.dart'; import 'package:solian/widgets/account/relative_list.dart';
import 'package:solian/widgets/root_container.dart';
class FriendScreen extends StatefulWidget { class FriendScreen extends StatefulWidget {
const FriendScreen({super.key}); const FriendScreen({super.key});
@ -117,8 +118,7 @@ class _FriendScreenState extends State<FriendScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
centerTitle: false, centerTitle: false,

View File

@ -1,9 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/models/notification.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/models/post.dart';
import 'package:solian/models/notification.dart' as notify; import 'package:solian/providers/notifications.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/relative_date.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class NotificationScreen extends StatefulWidget { class NotificationScreen extends StatefulWidget {
@ -14,57 +19,9 @@ class NotificationScreen extends StatefulWidget {
} }
class _NotificationScreenState extends State<NotificationScreen> { class _NotificationScreenState extends State<NotificationScreen> {
bool _isBusy = false;
Future<void> _markAllRead() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
setState(() => _isBusy = true);
final WebSocketProvider provider = Get.find();
List<int> markList = List.empty(growable: true);
for (final element in provider.notifications) {
if (element.id <= 0) continue;
markList.add(element.id);
}
if (markList.isNotEmpty) {
final client = await auth.configureClient('auth');
await client.put('/notifications/read', {'messages': markList});
}
provider.notifications.clear();
setState(() => _isBusy = false);
}
Future<void> _markOneRead(notify.Notification element, int index) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final WebSocketProvider provider = Get.find();
if (element.id <= 0) {
provider.notifications.removeAt(index);
return;
}
setState(() => _isBusy = true);
final client = await auth.configureClient('auth');
await client.put('/notifications/read/${element.id}', {});
provider.notifications.removeAt(index);
setState(() => _isBusy = false);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final WebSocketProvider ws = Get.find(); final NotificationProvider nty = Get.find();
return SizedBox( return SizedBox(
height: MediaQuery.of(context).size.height * 0.85, height: MediaQuery.of(context).size.height * 0.85,
@ -77,71 +34,174 @@ class _NotificationScreenState extends State<NotificationScreen> {
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
Expanded( Expanded(
child: Obx(() { child: Obx(() {
return CustomScrollView( return RefreshIndicator(
slivers: [ onRefresh: () => nty.fetchNotification(),
if (_isBusy) child: CustomScrollView(
slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(), child: LoadingIndicator(
), isActive: nty.isBusy.value,
if (ws.notifications.isEmpty)
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
color:
Theme.of(context).colorScheme.surfaceContainerHigh,
child: ListTile(
leading: const Icon(Icons.check),
title: Text('notifyEmpty'.tr),
subtitle: Text('notifyEmptyCaption'.tr),
),
), ),
), ),
if (ws.notifications.isNotEmpty) if (nty.notifications
SliverToBoxAdapter( .where((x) => x.readAt == null)
child: Container( .isEmpty)
padding: const EdgeInsets.symmetric(horizontal: 10), SliverToBoxAdapter(
color: Theme.of(context).colorScheme.secondaryContainer, child: Container(
child: ListTile( padding: const EdgeInsets.symmetric(horizontal: 10),
leading: const Icon(Icons.checklist), color: Theme.of(context)
title: Text('notifyAllRead'.tr), .colorScheme
onTap: _isBusy ? null : () => _markAllRead(), .surfaceContainerHigh,
child: ListTile(
leading: const Icon(Icons.check),
title: Text('notifyEmpty'.tr),
subtitle: Text('notifyEmptyCaption'.tr),
),
), ),
), ),
if (nty.notifications
.where((x) => x.readAt == null)
.isNotEmpty)
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
color:
Theme.of(context).colorScheme.secondaryContainer,
child: ListTile(
leading: const Icon(Icons.checklist),
title: Text('notifyAllRead'.tr),
onTap: nty.isBusy.value
? null
: () => nty.markAllRead(),
),
),
),
SliverList.separated(
itemCount: nty.notifications.length,
itemBuilder: (BuildContext context, int index) {
var element = nty.notifications[index];
return ClipRect(
child: Dismissible(
direction: element.readAt == null
? DismissDirection.horizontal
: DismissDirection.none,
key: Key(const Uuid().v4()),
background: Container(
color: Colors.lightBlue,
padding:
const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child:
const Icon(Icons.check, color: Colors.white),
),
secondaryBackground: Container(
color: Colors.lightBlue,
padding:
const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerRight,
child:
const Icon(Icons.check, color: Colors.white),
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 16,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(NotificationTopicIcons[element.topic]),
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
if (element.readAt == null)
Badge(
label: Row(
children: [
Icon(
Icons.new_releases_outlined,
color: Theme.of(context)
.colorScheme
.onSurface,
size: 12,
),
const Gap(4),
Text('unread'.tr),
],
),
).paddingOnly(bottom: 4),
Text(
element.title,
style: Theme.of(context)
.textTheme
.titleMedium,
),
if (element.subtitle != null)
Text(
element.subtitle!,
style: Theme.of(context)
.textTheme
.titleSmall,
),
if (element.subtitle != null)
const Gap(4),
MarkdownTextContent(
content: element.body,
isAutoWarp: true,
isSelectable: true,
parentId:
'notification-${element.id}',
),
if ([
'interactive.feedback',
'interactive.subscription'
].contains(element.topic) &&
element.metadata?['related_post'] !=
null)
_PostRelatedNotificationWidget(
metadata: element.metadata!,
),
const Gap(8),
Opacity(
opacity: 0.75,
child: Row(
children: [
RelativeDate(
element.createdAt,
style: TextStyle(fontSize: 12),
),
const Gap(4),
Text(
'·',
style: TextStyle(fontSize: 12),
),
const Gap(4),
RelativeDate(
element.createdAt,
style: TextStyle(fontSize: 12),
isFull: true,
),
],
),
),
],
),
),
],
),
),
onDismissed: (_) => nty.markOneRead(element, index),
),
);
},
separatorBuilder: (_, __) =>
const Divider(thickness: 0.3, height: 0.3),
), ),
SliverList.separated( ],
itemCount: ws.notifications.length, ),
itemBuilder: (BuildContext context, int index) {
var element = ws.notifications[index];
return Dismissible(
key: Key(const Uuid().v4()),
background: Container(
color: Colors.lightBlue,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child: const Icon(Icons.check, color: Colors.white),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
title: Text(element.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (element.subtitle != null)
Text(element.subtitle!),
Text(element.body),
],
),
),
onDismissed: (_) => _markOneRead(element, index),
);
},
separatorBuilder: (_, __) =>
const Divider(thickness: 0.3, height: 0.3),
),
],
); );
}), }),
), ),
@ -156,7 +216,7 @@ class NotificationButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final WebSocketProvider provider = Get.find(); final NotificationProvider nty = Get.find();
final button = IconButton( final button = IconButton(
icon: const Icon(Icons.notifications), icon: const Icon(Icons.notifications),
@ -166,16 +226,16 @@ class NotificationButton extends StatelessWidget {
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => const NotificationScreen(), builder: (context) => const NotificationScreen(),
).then((_) => provider.notificationUnread.value = 0); ).then((_) => nty.notificationUnread.value = 0);
}, },
); );
return Obx(() { return Obx(() {
if (provider.notificationUnread.value > 0) { if (nty.notificationUnread.value > 0) {
return Badge( return Badge(
isLabelVisible: true, isLabelVisible: true,
offset: const Offset(-8, 2), offset: const Offset(-8, 2),
label: Text(provider.notificationUnread.value.toString()), label: Text(nty.notificationUnread.value.toString()),
child: button, child: button,
); );
} else { } else {
@ -184,3 +244,31 @@ class NotificationButton extends StatelessWidget {
}); });
} }
} }
class _PostRelatedNotificationWidget extends StatelessWidget {
final Map<String, dynamic> metadata;
const _PostRelatedNotificationWidget({super.key, required this.metadata});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: PostItem(
item: Post.fromJson(metadata['related_post']),
isCompact: true,
).paddingAll(8),
),
onTap: () {
final data = Post.fromJson(metadata['related_post']);
Navigator.pop(context);
AppRouter.instance.pushNamed(
'postDetail',
pathParameters: {'id': data.id.toString()},
extra: data,
);
},
);
}
}

View File

@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get/get_connect/http/src/exceptions/exceptions.dart'; import 'package:get/get_connect/http/src/exceptions/exceptions.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/loading_indicator.dart';
class NotificationPreferencesScreen extends StatefulWidget { class NotificationPreferencesScreen extends StatefulWidget {
const NotificationPreferencesScreen({super.key}); const NotificationPreferencesScreen({super.key});
@ -59,10 +59,10 @@ class _NotificationPreferencesScreenState
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp)); context.showErrorDialog(RequestException(resp));
} else {
context.showSnackbar('preferencesApplied'.tr);
} }
context.showSnackbar('preferencesApplied'.tr);
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
@ -74,45 +74,42 @@ class _NotificationPreferencesScreenState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Column(
color: Theme.of(context).colorScheme.surface, children: [
child: Column( LoadingIndicator(isActive: _isBusy),
children: [ ListTile(
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), tileColor: Theme.of(context).colorScheme.surfaceContainer,
ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24),
tileColor: Theme.of(context).colorScheme.surfaceContainer, leading: const Icon(Icons.save),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), title: Text('save'.tr),
leading: const Icon(Icons.save), enabled: !_isBusy,
title: Text('save'.tr), onTap: () {
enabled: !_isBusy, _savePreferences();
onTap: () { },
_savePreferences(); ),
Expanded(
child: ListView.builder(
itemCount: _topicMap.length,
itemBuilder: (context, index) {
final element = _topicMap.entries.elementAt(index);
return CheckboxListTile(
title: Text(element.value),
subtitle: Text(
element.key,
style: GoogleFonts.robotoMono(fontSize: 12),
),
value: _config[element.key] ?? true,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (value) {
setState(() {
_config[element.key] = value ?? false;
});
},
);
}, },
), ),
Expanded( ),
child: ListView.builder( ],
itemCount: _topicMap.length,
itemBuilder: (context, index) {
final element = _topicMap.entries.elementAt(index);
return CheckboxListTile(
title: Text(element.value),
subtitle: Text(
element.key,
style: GoogleFonts.robotoMono(fontSize: 12),
),
value: _config[element.key] ?? true,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (value) {
setState(() {
_config[element.key] = value ?? false;
});
},
);
},
),
),
],
),
); );
} }
} }

View File

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:get/get_connect/http/src/exceptions/exceptions.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/loading_indicator.dart';
class AuthPreferencesScreen extends StatefulWidget {
const AuthPreferencesScreen({super.key});
@override
State<AuthPreferencesScreen> createState() => _AuthPreferencesScreenState();
}
class _AuthPreferencesScreenState extends State<AuthPreferencesScreen> {
bool _isBusy = true;
Map<String, dynamic> _config = {
'maximum_auth_steps': 2,
};
Future<void> _getPreferences() async {
setState(() => _isBusy = true);
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw UnauthorizedException();
final client = await auth.configureClient('id');
final resp = await client.get('/preferences/auth');
if (resp.statusCode != 200 && resp.statusCode != 404) {
context.showErrorDialog(RequestException(resp));
}
if (resp.statusCode == 200) {
_config = resp.body;
}
setState(() => _isBusy = false);
}
Future<void> _savePreferences() async {
setState(() => _isBusy = true);
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw UnauthorizedException();
final client = await auth.configureClient('id');
final resp = await client.put('/preferences/auth', _config);
if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp));
} else {
context.showSnackbar('preferencesApplied'.tr);
}
setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
_getPreferences();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.save),
title: Text('save'.tr),
enabled: !_isBusy,
onTap: () {
_savePreferences();
},
),
Expanded(
child: ListView(
children: [
ListTile(
title: Text('authMaximumAuthSteps'.tr),
subtitle: Text('authMaximumAuthStepsDesc'.tr),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: SizedBox(
width: 60,
child: _isBusy
? null
: TextFormField(
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
),
initialValue:
_config['maximum_auth_steps']?.toString() ?? '2',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly
],
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) {
_config['maximum_auth_steps'] =
int.tryParse(value) ?? 2;
},
),
),
),
],
),
),
],
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart'; import 'package:image_cropper/image_cropper.dart';
@ -12,6 +11,7 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart'; import 'package:solian/providers/content/attachment.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/loading_indicator.dart';
class PersonalizeScreen extends StatefulWidget { class PersonalizeScreen extends StatefulWidget {
const PersonalizeScreen({super.key}); const PersonalizeScreen({super.key});
@ -186,164 +186,161 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
const double padding = 32; const double padding = 32;
return Material( return ListView(
color: Theme.of(context).colorScheme.surface, children: [
child: ListView( LoadingIndicator(isActive: _isBusy),
children: [ const Gap(24),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), Stack(
const Gap(24), children: [
Stack( AttachedCircleAvatar(content: _avatar, radius: 40),
children: [ Positioned(
AccountAvatar(content: _avatar, radius: 40), bottom: 0,
Positioned( left: 40,
bottom: 0, child: FloatingActionButton.small(
left: 40, heroTag: const Key('avatar-editor'),
child: FloatingActionButton.small( onPressed: () => _editImage('avatar'),
heroTag: const Key('avatar-editor'), child: const Icon(
onPressed: () => _editImage('avatar'), Icons.camera,
child: const Icon(
Icons.camera,
),
), ),
), ),
],
).paddingSymmetric(horizontal: padding),
const Gap(16),
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null
? Image.network(
ServiceFinder.buildUrl(
'files', '/attachments/$_banner'),
fit: BoxFit.cover,
loadingBuilder: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes !=
null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
)
: Container(),
),
),
),
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton(
heroTag: const Key('banner-editor'),
onPressed: () => _editImage('banner'),
child: const Icon(
Icons.camera_alt,
),
),
),
],
).paddingSymmetric(horizontal: padding),
const Gap(24),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
readOnly: true,
controller: _usernameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'username'.tr,
prefixText: '@',
),
),
),
const Gap(16),
Flexible(
flex: 1,
child: TextField(
controller: _nicknameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'nickname'.tr,
),
),
),
],
).paddingSymmetric(horizontal: padding),
const Gap(16),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
controller: _firstNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'firstName'.tr,
),
),
),
const Gap(16),
Flexible(
flex: 1,
child: TextField(
controller: _lastNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'lastName'.tr,
),
),
),
],
).paddingSymmetric(horizontal: padding),
const Gap(16),
TextField(
controller: _descriptionController,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'description'.tr,
), ),
).paddingSymmetric(horizontal: padding), ],
const Gap(16), ).paddingSymmetric(horizontal: padding),
TextField( const Gap(16),
controller: _birthdayController, Stack(
readOnly: true, children: [
decoration: InputDecoration( ClipRRect(
border: const OutlineInputBorder(), borderRadius: const BorderRadius.all(Radius.circular(8)),
labelText: 'birthday'.tr, child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null
? Image.network(
ServiceFinder.buildUrl(
'files', '/attachments/$_banner'),
fit: BoxFit.cover,
loadingBuilder: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes !=
null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
)
: Container(),
),
),
), ),
onTap: () => _selectBirthday(), Positioned(
).paddingSymmetric(horizontal: padding), bottom: 16,
const Gap(16), right: 16,
Row( child: FloatingActionButton(
mainAxisAlignment: MainAxisAlignment.end, heroTag: const Key('banner-editor'),
children: [ onPressed: () => _editImage('banner'),
TextButton( child: const Icon(
onPressed: _isBusy ? null : () => _syncWidget(), Icons.camera_alt,
child: Text('reset'.tr), ),
), ),
ElevatedButton( ),
onPressed: _isBusy ? null : () => _editUserInfo(), ],
child: Text('apply'.tr), ).paddingSymmetric(horizontal: padding),
const Gap(24),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
readOnly: true,
controller: _usernameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'username'.tr,
prefixText: '@',
),
), ),
], ),
).paddingSymmetric(horizontal: padding), const Gap(16),
], Flexible(
), flex: 1,
child: TextField(
controller: _nicknameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'nickname'.tr,
),
),
),
],
).paddingSymmetric(horizontal: padding),
const Gap(16),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
controller: _firstNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'firstName'.tr,
),
),
),
const Gap(16),
Flexible(
flex: 1,
child: TextField(
controller: _lastNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'lastName'.tr,
),
),
),
],
).paddingSymmetric(horizontal: padding),
const Gap(16),
TextField(
controller: _descriptionController,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'description'.tr,
),
).paddingSymmetric(horizontal: padding),
const Gap(16),
TextField(
controller: _birthdayController,
readOnly: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'birthday'.tr,
),
onTap: () => _selectBirthday(),
).paddingSymmetric(horizontal: padding),
const Gap(16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isBusy ? null : () => _syncWidget(),
child: Text('reset'.tr),
),
ElevatedButton(
onPressed: _isBusy ? null : () => _editUserInfo(),
child: Text('apply'.tr),
),
],
).paddingSymmetric(horizontal: padding),
],
); );
} }

View File

@ -26,8 +26,8 @@ import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/attachments/attachment_list.dart'; import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/daily_sign/history_chart.dart'; import 'package:solian/widgets/daily_sign/history_chart.dart';
import 'package:solian/widgets/posts/post_list.dart'; import 'package:solian/widgets/posts/post_list.dart';
import 'package:solian/widgets/posts/post_warped_list.dart';
import 'package:solian/widgets/reports/abuse_report.dart'; import 'package:solian/widgets/reports/abuse_report.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
class AccountProfilePage extends StatefulWidget { class AccountProfilePage extends StatefulWidget {
@ -230,11 +230,14 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isBusy || _userinfo == null) { if (_isBusy || _userinfo == null) {
return const Center(child: CircularProgressIndicator()); return RootContainer(
child: const Center(
child: CircularProgressIndicator(),
),
);
} }
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: DefaultTabController( child: DefaultTabController(
length: 3, length: 3,
child: NestedScrollView( child: NestedScrollView(
@ -250,10 +253,15 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
height: 56, height: 56,
child: Row( child: Row(
children: [ children: [
AppBarLeadingButton.adaptive(context) ?? const Gap(8), AppBarLeadingButton.adaptive(
context,
forceBack: true,
) ??
const Gap(8),
const Gap(8), const Gap(8),
if (_userinfo != null) if (_userinfo != null)
AccountAvatar(content: _userinfo!.avatar, radius: 16), AttachedCircleAvatar(
content: _userinfo!.avatar, radius: 16),
const Gap(12), const Gap(12),
Expanded( Expanded(
child: Column( child: Column(
@ -340,7 +348,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
detail: _userinfo, detail: _userinfo,
profile: _userinfo!.profile, profile: _userinfo!.profile,
extraWidgets: [ extraWidgets: [
if (_dailySignRecords.isNotEmpty) if (_dailySignRecords.length > 1)
Card( Card(
child: SizedBox( child: SizedBox(
height: 180, height: 180,
@ -580,8 +588,6 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
color: color:
Theme.of(context).colorScheme.surfaceContainerLow, Theme.of(context).colorScheme.surfaceContainerLow,
child: PostListEntryWidget( child: PostListEntryWidget(
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
item: element, item: element,
isClickable: true, isClickable: true,
isNestedClickable: true, isNestedClickable: true,
@ -601,7 +607,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
), ),
if (_userinfo != null) if (_userinfo != null)
PostWarpedListWidget( ControlledPostListWidget(
isPinned: false, isPinned: false,
controller: _postController.pagingController, controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(), onUpdate: () => _postController.reloadAllOver(),

View File

@ -8,8 +8,8 @@ import 'package:solian/exts.dart';
import 'package:solian/models/auth.dart'; import 'package:solian/models/auth.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/realm.dart'; import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -178,7 +178,7 @@ class _SignInScreenState extends State<SignInScreen> {
Get.find<RealmProvider>().refreshAvailableRealms(); Get.find<RealmProvider>().refreshAvailableRealms();
Get.find<RelationshipProvider>().refreshRelativeList(); Get.find<RelationshipProvider>().refreshRelativeList();
Get.find<WebSocketProvider>().registerPushNotifications(); Get.find<NotificationProvider>().registerPushNotifications();
autoConfigureBackgroundNotificationService(); autoConfigureBackgroundNotificationService();
autoStartBackgroundNotificationService(); autoStartBackgroundNotificationService();
@ -216,10 +216,10 @@ class _SignInScreenState extends State<SignInScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return CenteredContainer(
color: Theme.of(context).colorScheme.surface, maxWidth: 360,
child: CenteredContainer( child: Theme(
maxWidth: 360, data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: PageTransitionSwitcher( child: PageTransitionSwitcher(
transitionBuilder: ( transitionBuilder: (
Widget child, Widget child,

View File

@ -65,148 +65,141 @@ class _SignUpScreenState extends State<SignUpScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return CenteredContainer(
color: Theme.of(context).colorScheme.surface, maxWidth: 360,
child: CenteredContainer( child: ListView(
maxWidth: 360, shrinkWrap: true,
child: ListView( children: [
shrinkWrap: true, Align(
children: [ alignment: Alignment.centerLeft,
Align( child: ClipRRect(
alignment: Alignment.centerLeft, borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ClipRRect( child: Image.asset('assets/logo.png', width: 64, height: 64),
borderRadius: const BorderRadius.all(Radius.circular(8)), ).paddingOnly(bottom: 8, left: 4),
child: Image.asset('assets/logo.png', width: 64, height: 64), ),
).paddingOnly(bottom: 8, left: 4), Text(
'signupGreeting'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
), ),
Text( ).paddingOnly(left: 4, bottom: 16),
'signupGreeting'.tr, TextField(
style: const TextStyle( autocorrect: false,
fontSize: 28, enableSuggestions: false,
fontWeight: FontWeight.w900, controller: _usernameController,
), autofillHints: const [AutofillHints.username],
).paddingOnly(left: 4, bottom: 16), decoration: InputDecoration(
TextField( isDense: true,
autocorrect: false, border: const OutlineInputBorder(),
enableSuggestions: false, labelText: 'username'.tr,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
TextField( ),
autocorrect: false, const Gap(12),
enableSuggestions: false, TextField(
controller: _nicknameController, autocorrect: false,
autofillHints: const [AutofillHints.nickname], enableSuggestions: false,
decoration: InputDecoration( controller: _nicknameController,
isDense: true, autofillHints: const [AutofillHints.nickname],
border: const OutlineInputBorder(), decoration: InputDecoration(
labelText: 'nickname'.tr, isDense: true,
), border: const OutlineInputBorder(),
onTapOutside: (_) => labelText: 'nickname'.tr,
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
TextField( ),
autocorrect: false, const Gap(12),
enableSuggestions: false, TextField(
controller: _emailController, autocorrect: false,
autofillHints: const [AutofillHints.email], enableSuggestions: false,
decoration: InputDecoration( controller: _emailController,
isDense: true, autofillHints: const [AutofillHints.email],
border: const OutlineInputBorder(), decoration: InputDecoration(
labelText: 'email'.tr, isDense: true,
), border: const OutlineInputBorder(),
onTapOutside: (_) => labelText: 'email'.tr,
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
TextField( ),
obscureText: true, const Gap(12),
autocorrect: false, TextField(
enableSuggestions: false, obscureText: true,
autofillHints: const [AutofillHints.password], autocorrect: false,
controller: _passwordController, enableSuggestions: false,
decoration: InputDecoration( autofillHints: const [AutofillHints.password],
isDense: true, controller: _passwordController,
border: const OutlineInputBorder(), decoration: InputDecoration(
labelText: 'password'.tr, isDense: true,
), border: const OutlineInputBorder(),
onTapOutside: (_) => labelText: 'password'.tr,
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => _performAction(context),
), ),
const Gap(8), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
CheckboxListTile( onSubmitted: (_) => _performAction(context),
value: _isTermAccepted, ),
title: Text( const Gap(8),
'termAccept'.tr, CheckboxListTile(
style: const TextStyle(height: 1.2), value: _isTermAccepted,
).paddingOnly(bottom: 4), title: Text(
shape: const RoundedRectangleBorder( 'termAccept'.tr,
borderRadius: BorderRadius.all( style: const TextStyle(height: 1.2),
Radius.circular(8), ).paddingOnly(bottom: 4),
), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
), ),
subtitle: RichText( ),
text: TextSpan( subtitle: RichText(
style: Theme.of(context).textTheme.bodySmall!.copyWith( text: TextSpan(
color: Theme.of(context) style: Theme.of(context).textTheme.bodySmall!.copyWith(
.colorScheme color: Theme.of(context)
.onSurface .colorScheme
.withOpacity(0.75), .onSurface
), .withOpacity(0.75),
children: [ ),
TextSpan(text: 'termAcceptDesc'.tr), children: [
WidgetSpan( TextSpan(text: 'termAcceptDesc'.tr),
child: Material( WidgetSpan(
color: Colors.transparent, child: Material(
child: InkWell( color: Colors.transparent,
child: Row( child: InkWell(
mainAxisSize: MainAxisSize.min, child: Row(
children: [ mainAxisSize: MainAxisSize.min,
Text('termAcceptLink'.tr), children: [
const Gap(4), Text('termAcceptLink'.tr),
const Icon(Icons.launch, size: 14), const Gap(4),
], const Icon(Icons.launch, size: 14),
), ],
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
), ),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
), ),
), ),
], ),
), ],
), ),
onChanged: (value) {
setState(() => _isTermAccepted = value ?? false);
},
), ),
const Gap(16), onChanged: (value) {
Align( setState(() => _isTermAccepted = value ?? false);
alignment: Alignment.centerRight, },
child: TextButton( ),
onPressed: const Gap(16),
!_isTermAccepted ? null : () => _performAction(context), Align(
child: Row( alignment: Alignment.centerRight,
mainAxisSize: MainAxisSize.min, child: TextButton(
children: [ onPressed:
Text('next'.tr), !_isTermAccepted ? null : () => _performAction(context),
const Icon(Icons.chevron_right), child: Row(
], mainAxisSize: MainAxisSize.min,
), children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
), ),
) ),
], )
), ],
).paddingAll(24), ).paddingAll(24),
); );
} }

View File

@ -11,6 +11,7 @@ import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/chat/call/call_controls.dart'; import 'package:solian/widgets/chat/call/call_controls.dart';
import 'package:solian/widgets/chat/call/call_participant.dart'; import 'package:solian/widgets/chat/call/call_participant.dart';
import 'package:livekit_client/livekit_client.dart' as livekit; import 'package:livekit_client/livekit_client.dart' as livekit;
import 'package:solian/widgets/root_container.dart';
class CallScreen extends StatefulWidget { class CallScreen extends StatefulWidget {
final bool hideAppBar; final bool hideAppBar;
@ -197,8 +198,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ChatCallProvider ctrl = Get.find(); final ChatCallProvider ctrl = Get.find();
return Material( return ResponsiveRootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: widget.hideAppBar appBar: widget.hideAppBar
? null ? null

View File

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/call.dart'; import 'package:solian/models/call.dart';
@ -25,6 +26,7 @@ import 'package:solian/widgets/chat/chat_event_list.dart';
import 'package:solian/widgets/chat/chat_message_input.dart'; import 'package:solian/widgets/chat/chat_message_input.dart';
import 'package:solian/widgets/chat/chat_typing_indicator.dart'; import 'package:solian/widgets/chat/chat_typing_indicator.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/root_container.dart';
class ChannelChatScreen extends StatefulWidget { class ChannelChatScreen extends StatefulWidget {
final String alias; final String alias;
@ -179,6 +181,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
} }
} }
late SharedPreferences _prefs;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -189,10 +193,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
_chatController = ChatEventController(); _chatController = ChatEventController();
_chatController.initialize(); _chatController.initialize();
_getOngoingCall(); SharedPreferences.getInstance().then((inst) {
_getChannel().then((_) { _prefs = inst;
_chatController.getInitialEvents(_channel!, widget.realm); _getOngoingCall();
_listenMessages(); _getChannel().then((_) {
_chatController.getInitialEvents(_channel!, widget.realm);
_listenMessages();
});
}); });
} }
@ -211,143 +218,149 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
); );
} }
return Scaffold( return ResponsiveRootContainer(
appBar: AppBar( child: Scaffold(
leading: AppBarLeadingButton.adaptive(context), appBar: AppBar(
title: AppBarTitle(title), leading: AppBarLeadingButton.adaptive(context),
centerTitle: false, title: AppBarTitle(title),
titleSpacing: AppTheme.titleSpacing(context), centerTitle: false,
toolbarHeight: AppTheme.toolbarHeight(context), titleSpacing: AppTheme.titleSpacing(context),
actions: [ toolbarHeight: AppTheme.toolbarHeight(context),
const BackgroundStateWidget(), actions: [
Builder(builder: (context) { const BackgroundStateWidget(),
if (_isBusy || _channel == null) return const SizedBox.shrink(); Builder(builder: (context) {
if (_isBusy || _channel == null) return const SizedBox.shrink();
return ChatCallButton( return ChatCallButton(
realm: _channel!.realm, realm: _channel!.realm,
channel: _channel!, channel: _channel!,
ongoingCall: _ongoingCall, ongoingCall: _ongoingCall,
); );
}), }),
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
onPressed: () { onPressed: () {
if (_channel == null) return; if (_channel == null) return;
AppRouter.instance AppRouter.instance
.pushNamed( .pushNamed(
'channelDetail', 'channelDetail',
pathParameters: {'alias': widget.alias}, pathParameters: {'alias': widget.alias},
queryParameters: {'realm': widget.realm}, queryParameters: {'realm': widget.realm},
extra: ChannelDetailArguments( extra: ChannelDetailArguments(
profile: _channelProfile!, profile: _channelProfile!,
channel: _channel!, channel: _channel!,
),
)
.then((value) {
if (value == false) AppRouter.instance.pop();
if (value != null) {
final resp = Channel.fromJson(value as Map<String, dynamic>);
_getChannel(alias: resp.alias);
}
});
},
),
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
body: Builder(builder: (context) {
if (_isBusy || _channel == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Row(
children: [
Expanded(
child: Column(
children: [
if (_ongoingCall != null)
ChannelCallIndicator(
channel: _channel!,
ongoingCall: _ongoingCall!,
onJoin: () {
if (!AppTheme.isUltraLargeScreen(context)) {
final ChatCallProvider call = Get.find();
call.gotoScreen(context);
}
},
),
Expanded(
child: ChatEventList(
scope: widget.realm,
channel: _channel!,
chatController: _chatController,
onEdit: (item) {
setState(() => _messageToEditing = item);
},
onReply: (item) {
setState(() => _messageToReplying = item);
},
),
), ),
ClipRect( )
child: BackdropFilter( .then((value) {
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), if (value == false) AppRouter.instance.pop();
child: SafeArea( if (value != null) {
child: Column( final resp =
children: [ Channel.fromJson(value as Map<String, dynamic>);
ChatTypingIndicator(users: _typingUsers), _getChannel(alias: resp.alias);
ChatMessageInput( }
edit: _messageToEditing, });
reply: _messageToReplying, },
realm: widget.realm, ),
placeholder: placeholder, SizedBox(
channel: _channel!, width: AppTheme.isLargeScreen(context) ? 8 : 16,
onSent: (Event item) { ),
setState(() { ],
_chatController.addPendingEvent(item); ),
}); body: Builder(builder: (context) {
}, if (_isBusy || _channel == null) {
onReset: () { return const Center(
setState(() { child: CircularProgressIndicator(),
_messageToReplying = null; );
_messageToEditing = null; }
});
}, return Row(
), children: [
], Expanded(
child: Column(
children: [
if (_ongoingCall != null)
ChannelCallIndicator(
channel: _channel!,
ongoingCall: _ongoingCall!,
onJoin: () {
if (!AppTheme.isUltraLargeScreen(context)) {
final ChatCallProvider call = Get.find();
call.gotoScreen(context);
}
},
),
Expanded(
child: ChatEventList(
noAnimated:
_prefs.getBool('non_animated_message_list') ??
false,
scope: widget.realm,
channel: _channel!,
chatController: _chatController,
onEdit: (item) {
setState(() => _messageToEditing = item);
},
onReply: (item) {
setState(() => _messageToReplying = item);
},
),
),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: SafeArea(
child: Column(
children: [
ChatTypingIndicator(users: _typingUsers),
ChatMessageInput(
edit: _messageToEditing,
reply: _messageToReplying,
realm: widget.realm,
placeholder: placeholder,
channel: _channel!,
onSent: (Event item) {
setState(() {
_chatController.addPendingEvent(item);
});
},
onReset: () {
setState(() {
_messageToReplying = null;
_messageToEditing = null;
});
},
),
],
),
), ),
), ),
), ),
), ],
], ),
), ),
), Obx(() {
Obx(() { final ChatCallProvider call = Get.find();
final ChatCallProvider call = Get.find(); if (call.isMounted.value &&
if (call.isMounted.value && AppTheme.isUltraLargeScreen(context)) {
AppTheme.isUltraLargeScreen(context)) { return const Expanded(
return const Expanded( child: Row(children: [
child: Row(children: [ VerticalDivider(width: 0.3, thickness: 0.3),
VerticalDivider(width: 0.3, thickness: 0.3), Expanded(
Expanded( child: CallScreen(
child: CallScreen( hideAppBar: true,
hideAppBar: true, isExpandable: true,
isExpandable: true, ),
), ),
), ]),
]), );
); }
} return const SizedBox.shrink();
return const SizedBox.shrink(); }),
}), ],
], );
); }),
}), ),
); );
} }

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -9,6 +8,8 @@ import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class ChannelOrganizeArguments { class ChannelOrganizeArguments {
@ -114,8 +115,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
), ),
]; ];
return Material( return ResponsiveRootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: AppBarTitle('channelOrganizing'.tr), title: AppBarTitle('channelOrganizing'.tr),
@ -132,7 +132,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
top: false, top: false,
child: Column( child: Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
if (widget.edit != null) if (widget.edit != null)
MaterialBanner( MaterialBanner(
leading: const Icon(Icons.edit), leading: const Icon(Icons.edit),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_resizable_container/flutter_resizable_container.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -19,6 +20,8 @@ import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/channel/channel_list.dart'; import 'package:solian/widgets/channel/channel_list.dart';
import 'package:solian/widgets/chat/call/chat_call_indicator.dart'; import 'package:solian/widgets/chat/call/chat_call_indicator.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sidebar/empty_placeholder.dart'; import 'package:solian/widgets/sidebar/empty_placeholder.dart';
class ChatScreen extends StatelessWidget { class ChatScreen extends StatelessWidget {
@ -26,9 +29,8 @@ class ChatScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return const ResponsiveRootContainer(
color: Theme.of(context).colorScheme.surface, child: ChatList(),
child: const ChatList(),
); );
} }
} }
@ -40,16 +42,24 @@ class ChatListShell extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface, child: ResizableContainer(
child: Row( direction: Axis.horizontal,
divider: ResizableDivider(
thickness: 0.3,
color: Theme.of(context).dividerColor.withOpacity(0.3),
),
children: [ children: [
const SizedBox( const ResizableChild(
width: 360, minSize: 280,
maxSize: 520,
size: ResizableSize.pixels(360),
child: ChatList(), child: ChatList(),
), ),
const VerticalDivider(thickness: 0.3, width: 0.3), ResizableChild(
Expanded(child: child ?? const EmptyPagePlaceholder()), minSize: 280,
child: child ?? const EmptyPagePlaceholder(),
),
], ],
), ),
); );
@ -70,6 +80,8 @@ class _ChatListState extends State<ChatList> {
late final ChannelProvider _channels = Get.find(); late final ChannelProvider _channels = Get.find();
bool _isBusy = true;
List<Channel> _sortChannels(List<Channel> channels) { List<Channel> _sortChannels(List<Channel> channels) {
channels.sort( channels.sort(
(a, b) => (a, b) =>
@ -118,18 +130,25 @@ class _ChatListState extends State<ChatList> {
final ctrl = ChatEventController(); final ctrl = ChatEventController();
await ctrl.initialize(); await ctrl.initialize();
final messages = await ctrl.src.getLastInAllChannels(); final messages = await ctrl.src.getLastInAllChannels();
setState(() { if (mounted) {
_lastMessages = messages setState(() {
.map((k, v) => MapEntry(k, v.firstOrNull)) _lastMessages = messages
.cast<int, LocalMessageEventTableData>(); .map((k, v) => MapEntry(k, v.firstOrNull))
}); .cast<int, LocalMessageEventTableData>();
});
}
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadLastMessages().then((_) { _loadLastMessages().then((_) {
_loadAllChannels(); if (!mounted) return;
_loadAllChannels().then((_) {
if (mounted) {
setState(() => _isBusy = false);
}
});
}); });
} }
@ -141,181 +160,178 @@ class _ChatListState extends State<ChatList> {
return Obx( return Obx(
() => DefaultTabController( () => DefaultTabController(
length: 2 + realms.availableRealms.length, length: 2 + realms.availableRealms.length,
child: Scaffold( child: ResponsiveRootContainer(
appBar: AppBar( child: Scaffold(
leading: Obx(() { appBar: AppBar(
final adaptive = AppBarLeadingButton.adaptive(context); leading: AppBarLeadingButton.adaptive(context),
if (adaptive != null) return adaptive; title: AppBarTitle('chat'.tr),
if (_channels.isLoading.value) { centerTitle: true,
return const CircularProgressIndicator( toolbarHeight: AppTheme.toolbarHeight(context),
strokeWidth: 3, actions: [
).paddingAll(18); const BackgroundStateWidget(),
} const NotificationButton(),
return const SizedBox.shrink(); PopupMenuButton(
}), icon: const Icon(Icons.add_circle),
title: AppBarTitle('chat'.tr), itemBuilder: (BuildContext context) => [
centerTitle: true, PopupMenuItem(
toolbarHeight: AppTheme.toolbarHeight(context), child: ListTile(
actions: [ title: Text('channelOrganizeCommon'.tr),
const BackgroundStateWidget(), leading: const Icon(Icons.tag),
const NotificationButton(), contentPadding:
PopupMenuButton( const EdgeInsets.symmetric(horizontal: 8),
icon: const Icon(Icons.add_circle), ),
itemBuilder: (BuildContext context) => [ onTap: () {
PopupMenuItem( AppRouter.instance.pushNamed('channelOrganizing').then(
child: ListTile( (value) {
title: Text('channelOrganizeCommon'.tr), if (value != null) {
leading: const Icon(Icons.tag), _loadAllChannels();
contentPadding: const EdgeInsets.symmetric(horizontal: 8), }
},
);
},
), ),
onTap: () { PopupMenuItem(
AppRouter.instance.pushNamed('channelOrganizing').then( child: ListTile(
(value) { title: Text('channelOrganizeDirect'.tr),
if (value != null) { leading: const FaIcon(
FontAwesomeIcons.userGroup,
size: 16,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 8),
),
onTap: () {
final ChannelProvider channels = Get.find();
channels
.createDirectChannel(context, 'global')
.then((resp) {
if (resp != null) {
_loadAllChannels(); _loadAllChannels();
} }
}, }).catchError((e) {
); context.showErrorDialog(e);
}, });
), },
PopupMenuItem( ),
child: ListTile( ],
title: Text('channelOrganizeDirect'.tr), ),
leading: const FaIcon( SizedBox(
FontAwesomeIcons.userGroup, width: AppTheme.isLargeScreen(context) ? 8 : 16,
size: 16, ),
), ],
contentPadding: const EdgeInsets.symmetric(horizontal: 8), bottom: TabBar(
isScrollable: true,
dividerHeight: 0.3,
tabAlignment: TabAlignment.startOffset,
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 14,
backgroundColor:
Theme.of(context).colorScheme.primary,
child: const Icon(
Icons.forum,
size: 16,
color: Colors.white,
),
),
const Gap(8),
Text('all'.tr),
],
), ),
onTap: () {
final ChannelProvider channels = Get.find();
channels
.createDirectChannel(context, 'global')
.then((resp) {
if (resp != null) {
_loadAllChannels();
}
}).catchError((e) {
context.showErrorDialog(e);
});
},
), ),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
radius: 14,
child: Icon(
Icons.chat_bubble,
size: 16,
),
),
const Gap(8),
Text('channelTypeDirect'.tr),
],
),
),
...realms.availableRealms.map((x) => Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AttachedCircleAvatar(
content: x.avatar,
radius: 14,
fallbackWidget: const Icon(
Icons.workspaces,
size: 16,
),
),
const Gap(8),
Text(x.name),
],
),
)),
], ],
), ),
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
],
bottom: TabBar(
isScrollable: true,
dividerHeight: 0.3,
tabAlignment: TabAlignment.startOffset,
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 14,
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(
Icons.forum,
size: 16,
color: Colors.white,
),
),
const Gap(8),
Text('all'.tr),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
radius: 14,
child: Icon(
Icons.chat_bubble,
size: 16,
),
),
const Gap(8),
Text('channelTypeDirect'.tr),
],
),
),
...realms.availableRealms.map((x) => Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AccountAvatar(
content: x.avatar,
radius: 14,
fallbackWidget: const Icon(
Icons.workspaces,
size: 16,
),
),
const Gap(8),
Text(x.name),
],
),
)),
],
), ),
), body: Obx(() {
body: Obx(() { if (auth.isAuthorized.isFalse) {
if (auth.isAuthorized.isFalse) { return SigninRequiredOverlay(
return SigninRequiredOverlay( onDone: () => _loadAllChannels(),
onDone: () => _loadAllChannels(), );
); }
}
final selfId = auth.userProfile.value!['id']; final selfId = auth.userProfile.value!['id'];
return Column( return Column(
children: [ children: [
const ChatCallCurrentIndicator(), const ChatCallCurrentIndicator(),
Expanded( LoadingIndicator(isActive: _isBusy),
child: TabBarView( Expanded(
children: [ child: TabBarView(
RefreshIndicator( children: [
onRefresh: _loadNormalChannels, RefreshIndicator(
child: ChannelListWidget( onRefresh: _loadNormalChannels,
channels: _sortChannels([
..._normalChannels,
..._directChannels,
..._realmChannels.values.expand((x) => x),
]),
selfId: selfId,
useReplace: AppTheme.isLargeScreen(context),
),
),
RefreshIndicator(
onRefresh: _loadDirectChannels,
child: ChannelListWidget(
channels: _directChannels,
selfId: selfId,
useReplace: AppTheme.isLargeScreen(context),
),
),
...realms.availableRealms.map(
(x) => RefreshIndicator(
onRefresh: () => _loadRealmChannels(x.alias),
child: ChannelListWidget( child: ChannelListWidget(
channels: _realmChannels[x.alias] ?? [], channels: _sortChannels([
..._normalChannels,
..._directChannels,
..._realmChannels.values.expand((x) => x),
]),
selfId: selfId, selfId: selfId,
useReplace: AppTheme.isLargeScreen(context), useReplace: AppTheme.isLargeScreen(context),
), ),
), ),
), RefreshIndicator(
], onRefresh: _loadDirectChannels,
child: ChannelListWidget(
channels: _directChannels,
selfId: selfId,
useReplace: AppTheme.isLargeScreen(context),
),
),
...realms.availableRealms.map(
(x) => RefreshIndicator(
onRefresh: () => _loadRealmChannels(x.alias),
child: ChannelListWidget(
channels: _realmChannels[x.alias] ?? [],
selfId: selfId,
useReplace: AppTheme.isLargeScreen(context),
),
),
),
],
),
), ),
), ],
], );
); }),
}), ),
), ),
), ),
); );

View File

@ -20,7 +20,7 @@ import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/daily_sign.dart'; import 'package:solian/providers/daily_sign.dart';
import 'package:solian/providers/database/services/messages.dart'; import 'package:solian/providers/database/services/messages.dart';
import 'package:solian/providers/last_read.dart'; import 'package:solian/providers/last_read.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/notifications.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart'; import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
@ -38,7 +38,7 @@ class DashboardScreen extends StatefulWidget {
class _DashboardScreenState extends State<DashboardScreen> { class _DashboardScreenState extends State<DashboardScreen> {
late final AuthProvider _auth = Get.find(); late final AuthProvider _auth = Get.find();
late final LastReadProvider _lastRead = Get.find(); late final LastReadProvider _lastRead = Get.find();
late final WebSocketProvider _ws = Get.find(); late final NotificationProvider _nty = Get.find();
late final PostProvider _posts = Get.find(); late final PostProvider _posts = Get.find();
late final DailySignProvider _dailySign = Get.find(); late final DailySignProvider _dailySign = Get.find();
@ -46,7 +46,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
Theme.of(context).colorScheme.onSurface.withOpacity(0.75); Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
List<Notification> get _pendingNotifications => List<Notification> get _pendingNotifications =>
List<Notification>.from(_ws.notifications) List<Notification>.from(_nty.notifications.where((x) => x.readAt == null))
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); ..sort((a, b) => b.createdAt.compareTo(a.createdAt));
List<Post>? _currentPosts; List<Post>? _currentPosts;
@ -75,10 +75,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
final src = Get.find<MessagesFetchingProvider>(); final src = Get.find<MessagesFetchingProvider>();
final out = await src.getWhatsNewEvents(_lastRead.messagesLastReadAt!); final out = await src.getWhatsNewEvents(_lastRead.messagesLastReadAt!);
if (out == null) return; if (out == null) return;
setState(() { if (mounted) {
_currentMessages = out.$1; setState(() {
_currentMessagesCount = out.$2; _currentMessages = out.$1;
}); _currentMessagesCount = out.$2;
});
}
} }
bool _signingDaily = true; bool _signingDaily = true;
@ -89,13 +91,17 @@ class _DashboardScreenState extends State<DashboardScreen> {
try { try {
_signRecord = await _dailySign.getToday(); _signRecord = await _dailySign.getToday();
_dailySign.listLastRecord(14).then((value) { _dailySign.listLastRecord(14).then((value) {
setState(() => _signRecordHistory = value); if (mounted) {
setState(() => _signRecordHistory = value);
}
}); });
} catch (e) { } catch (e) {
context.showErrorDialog(e); context.showErrorDialog(e);
} }
setState(() => _signingDaily = false); if (mounted) {
setState(() => _signingDaily = false);
}
} }
Future<void> _signDaily() async { Future<void> _signDaily() async {
@ -147,7 +153,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
Text(DateFormat('yyyy/MM/dd').format(DateTime.now().toUtc())), Text(DateFormat('yyyy/MM/dd').format(DateTime.now().toUtc())),
], ],
).paddingOnly(top: 8, left: 18, right: 18, bottom: 12), ).paddingOnly(top: 16, left: 18, right: 18, bottom: 12),
Card( Card(
child: Column( child: Column(
children: [ children: [
@ -248,7 +254,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
Text( Text(
'notificationUnreadCount'.trParams({ 'notificationUnreadCount'.trParams({
'count': _ws.notifications.length.toString(), 'count': _pendingNotifications.length.toString(),
}), }),
), ),
], ],
@ -261,12 +267,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => const NotificationScreen(), builder: (context) => const NotificationScreen(),
).then((_) => _ws.notificationUnread.value = 0); ).then((_) => _nty.notificationUnread.value = 0);
}, },
), ),
], ],
).paddingOnly(left: 18, right: 18, bottom: 8), ).paddingOnly(left: 18, right: 18, bottom: 8),
if (_ws.notifications.isNotEmpty) if (_pendingNotifications.isNotEmpty)
SizedBox( SizedBox(
height: 76, height: 76,
child: ListView.separated( child: ListView.separated(
@ -354,7 +360,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
IconButton( IconButton(
icon: const Icon(Icons.arrow_forward), icon: const Icon(Icons.arrow_forward),
onPressed: () { onPressed: () {
AppRouter.instance.goNamed('feed'); AppRouter.instance.goNamed('explore');
}, },
), ),
], ],
@ -383,9 +389,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
onUpdate: (_) { onUpdate: (_) {
_pullPosts(); _pullPosts();
}, },
backgroundColor: Theme.of(context) padding: EdgeInsets.symmetric(
.colorScheme vertical: 8,
.surfaceContainerLow, horizontal: 4,
),
), ),
), ),
), ),
@ -519,7 +526,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
style: TextStyle(color: _unFocusColor, fontSize: 12), style: TextStyle(color: _unFocusColor, fontSize: 12),
) )
], ],
).paddingAll(8), ).paddingOnly(left: 8, right: 8, top: 8, bottom: 50),
], ],
), ),
); );

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@ -13,8 +14,10 @@ import 'package:solian/widgets/account/signin_required_overlay.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/navigation/realm_switcher.dart'; import 'package:solian/widgets/navigation/realm_switcher.dart';
import 'package:solian/widgets/posts/post_creation.dart';
import 'package:solian/widgets/posts/post_list.dart';
import 'package:solian/widgets/posts/post_shuffle_swiper.dart'; import 'package:solian/widgets/posts/post_shuffle_swiper.dart';
import 'package:solian/widgets/posts/post_warped_list.dart'; import 'package:solian/widgets/root_container.dart';
class ExploreScreen extends StatefulWidget { class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key}); const ExploreScreen({super.key});
@ -56,8 +59,7 @@ class _ExploreScreenState extends State<ExploreScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add), child: const Icon(Icons.add),
@ -80,62 +82,98 @@ class _ExploreScreenState extends State<ExploreScreen>
body: NestedScrollView( body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [ return [
SliverAppBar( SliverLayoutBuilder(
flexibleSpace: SizedBox( builder: (context, constraints) {
height: 48, final scrollOffset = constraints.scrollOffset;
child: const Row( final colorChangeOffset = 120;
children: [
RealmSwitcher(), final scrollProgress =
(scrollOffset / colorChangeOffset).clamp(0.0, 1.0);
final blurSigma = lerpDouble(0, 10, scrollProgress) ?? 0;
return SliverAppBar(
flexibleSpace: ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: blurSigma,
sigmaY: blurSigma,
),
child: ListView(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
children: [
SizedBox(
height: 48,
child: const Row(
children: [
RealmSwitcher(),
],
).paddingSymmetric(horizontal: 8),
).paddingSymmetric(vertical: 4),
TabBar(
controller: _tabController,
dividerHeight: scrollProgress > 0 ? 0 : 0.3,
tabAlignment: TabAlignment.fill,
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.feed, size: 20),
const Gap(8),
Text('postListNews'.tr),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.people, size: 20),
const Gap(8),
Text('postListFriends'.tr),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.shuffle_on_outlined,
size: 20,
),
const Gap(8),
Text('postListShuffle'.tr),
],
),
),
],
),
],
).paddingOnly(top: MediaQuery.of(context).padding.top),
),
),
expandedHeight: 104,
snap: true,
floating: true,
toolbarHeight: AppTheme.toolbarHeight(context),
leading: AppBarLeadingButton.adaptive(context),
actions: [
const BackgroundStateWidget(),
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
AppRouter.instance.pushNamed('postSearch');
},
),
const NotificationButton(),
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
], ],
).paddingSymmetric(horizontal: 8), );
).paddingOnly(top: MediaQuery.of(context).padding.top), },
floating: true,
toolbarHeight: AppTheme.toolbarHeight(context),
leading: AppBarLeadingButton.adaptive(context),
actions: [
const BackgroundStateWidget(),
const NotificationButton(),
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
],
bottom: TabBar(
controller: _tabController,
dividerHeight: 0.3,
tabAlignment: TabAlignment.fill,
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.feed, size: 20),
const Gap(8),
Text('postListNews'.tr),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.people, size: 20),
const Gap(8),
Text('postListFriends'.tr),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.shuffle_on_outlined, size: 20),
const Gap(8),
Text('postListShuffle'.tr),
],
),
),
],
),
) )
]; ];
}, },
@ -156,7 +194,13 @@ class _ExploreScreenState extends State<ExploreScreen>
RefreshIndicator( RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(), onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [ child: CustomScrollView(slivers: [
PostWarpedListWidget( ControlledPostListWidget(
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
)
: EdgeInsets.zero,
controller: _postController.pagingController, controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(), onUpdate: () => _postController.reloadAllOver(),
), ),
@ -167,7 +211,10 @@ class _ExploreScreenState extends State<ExploreScreen>
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(), onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [ child: CustomScrollView(slivers: [
PostWarpedListWidget( ControlledPostListWidget(
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero,
controller: _postController.pagingController, controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(), onUpdate: () => _postController.reloadAllOver(),
), ),
@ -202,106 +249,3 @@ class _ExploreScreenState extends State<ExploreScreen>
super.dispose(); super.dispose();
} }
} }
class PostCreatePopup extends StatelessWidget {
final bool hideDraftBox;
const PostCreatePopup({
super.key,
this.hideDraftBox = false,
});
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) {
return const SizedBox.shrink();
}
final List<dynamic> actionList = [
(
icon: const Icon(Icons.post_add),
label: 'postEditorModeStory'.tr,
onTap: () {
Navigator.pop(
context,
AppRouter.instance.pushNamed(
'postEditor',
queryParameters: {
'mode': 0.toString(),
},
),
);
},
),
(
icon: const Icon(Icons.description),
label: 'postEditorModeArticle'.tr,
onTap: () {
Navigator.pop(
context,
AppRouter.instance.pushNamed(
'postEditor',
queryParameters: {
'mode': 1.toString(),
},
),
);
},
),
(
icon: const Icon(Icons.drafts),
label: 'draftBoxOpen'.tr,
onTap: () {
Navigator.pop(
context,
AppRouter.instance.pushNamed('draftBox'),
);
},
),
];
return SizedBox(
height: MediaQuery.of(context).size.height * 0.38,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'postNew'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
Expanded(
child: GridView.count(
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
children: actionList
.map((x) => Card(
color: Theme.of(context).colorScheme.surfaceContainer,
child: InkWell(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
onTap: x.onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
x.icon,
const Gap(8),
Expanded(
child: Text(
x.label,
overflow: TextOverflow.fade,
),
),
],
).paddingAll(18),
),
))
.toList(),
).paddingSymmetric(horizontal: 20),
),
],
),
);
}
}

View File

@ -1,114 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_owned_list.dart';
class DraftBoxScreen extends StatefulWidget {
const DraftBoxScreen({super.key});
@override
State<DraftBoxScreen> createState() => _DraftBoxScreenState();
}
class _DraftBoxScreenState extends State<DraftBoxScreen> {
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
_getPosts(int pageKey) async {
final PostProvider provider = Get.find();
Response resp;
try {
resp = await provider.listDraft(pageKey);
} catch (e) {
_pagingController.error = e;
return;
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
if (result.count == 0) {
_pagingController.appendLastPage([]);
return;
}
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
_pagingController.appendLastPage(parsed);
}
}
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_getPosts);
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle('draftBox'.tr),
centerTitle: false,
toolbarHeight: AppTheme.toolbarHeight(context),
actions: [
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
body: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: PagedListView<int, Post>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) {
return PostOwnedListEntry(
item: item,
isFullContent: true,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
onTap: () async {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(
item: item,
noReact: true,
),
).then((value) {
if (value is Future) {
value.then((_) {
_pagingController.refresh();
});
} else if (value != null) {
_pagingController.refresh();
}
});
},
).paddingOnly(left: 12, right: 12, bottom: 4);
},
),
),
),
),
);
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
}

View File

@ -1,99 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/posts/post_warped_list.dart';
import '../../models/post.dart';
class FeedSearchScreen extends StatefulWidget {
final String? tag;
final String? category;
const FeedSearchScreen({super.key, this.tag, this.category});
@override
State<FeedSearchScreen> createState() => _FeedSearchScreenState();
}
class _FeedSearchScreenState extends State<FeedSearchScreen> {
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
getPosts(int pageKey) async {
final PostProvider provider = Get.find();
Response resp;
try {
resp = await provider.listPost(
pageKey,
tag: widget.tag,
category: widget.category,
);
} catch (e) {
_pagingController.error = e;
return;
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
_pagingController.appendLastPage(parsed);
}
}
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(getPosts);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Material(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
if (widget.tag != null)
ListTile(
leading: const Icon(Icons.label),
tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('postSearchWithTag'.trParams({'key': widget.tag!})),
),
if (widget.category != null)
ListTile(
leading: const Icon(Icons.category),
tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('postSearchWithCategory'
.trParams({'key': widget.category!})),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
PostWarpedListWidget(
controller: _pagingController,
onUpdate: () => _pagingController.refresh(),
),
],
),
),
),
],
),
),
);
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class DraftBoxScreen extends StatefulWidget {
const DraftBoxScreen({super.key});
@override
State<DraftBoxScreen> createState() => _DraftBoxScreenState();
}
class _DraftBoxScreenState extends State<DraftBoxScreen> {
bool _isBusy = true;
int? _totalPosts;
final List<Post> _posts = List.empty(growable: true);
_getPosts() async {
setState(() => _isBusy = true);
final PostProvider posts = Get.find();
final resp = await posts.listDraft(_posts.length);
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
_totalPosts = result.count;
_posts.addAll(parsed ?? List.empty());
setState(() => _isBusy = false);
}
Future<void> _openActions(Post item) async {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(
item: item,
noReact: true,
),
).then((value) {
if (value is Future) {
value.then((_) {
_posts.clear();
_getPosts();
});
} else if (value != null) {
_posts.clear();
_getPosts();
}
});
}
@override
void initState() {
super.initState();
_getPosts();
}
@override
Widget build(BuildContext context) {
return RootContainer(
child: Scaffold(
appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle('draftBox'.tr),
centerTitle: false,
toolbarHeight: AppTheme.toolbarHeight(context),
actions: [
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_posts.clear();
return _getPosts();
},
child: InfiniteList(
itemCount: _posts.length,
hasReachedMax: _totalPosts == _posts.length,
isLoading: _isBusy,
onFetchData: () => _getPosts(),
itemBuilder: (context, index) {
final item = _posts[index];
return Card(
child: GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PostItem(
key: Key('p${item.id}'),
item: item,
isShowEmbed: false,
isClickable: false,
isShowReply: false,
isReactable: false,
onTapMore: () => _openActions(item),
).paddingSymmetric(vertical: 8),
],
),
onTap: () => _openActions(item),
),
).paddingOnly(left: 12, right: 12, bottom: 4);
},
),
),
),
],
),
),
);
}
}

View File

@ -4,6 +4,9 @@ import 'package:solian/exts.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/last_read.dart'; import 'package:solian/providers/last_read.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/posts/post_replies.dart'; import 'package:solian/widgets/posts/post_replies.dart';
@ -22,76 +25,109 @@ class PostDetailScreen extends StatefulWidget {
} }
class _PostDetailScreenState extends State<PostDetailScreen> { class _PostDetailScreenState extends State<PostDetailScreen> {
Post? item; bool _isBusy = true;
Future<Post?> getDetail() async { Post? _item;
if (widget.post != null) {
item = widget.post;
Get.find<LastReadProvider>().feedLastReadAt = item?.id;
return widget.post;
}
final PostProvider provider = Get.find(); Future<void> _getDetail() async {
final PostProvider posts = Get.find();
try { try {
final resp = await provider.getPost(widget.id); final resp = await posts.getPost(widget.id);
item = Post.fromJson(resp.body); _item = Post.fromJson(resp.body);
} catch (e) { } catch (e) {
context.showErrorDialog(e).then((_) => Navigator.pop(context)); context.showErrorDialog(e).then((_) => Navigator.pop(context));
} }
Get.find<LastReadProvider>().feedLastReadAt = item?.id; Get.find<LastReadProvider>().feedLastReadAt = _item?.id;
return item; if (mounted) setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
if (widget.post != null) {
_item = widget.post;
}
_getDetail();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( if (_isBusy && _item == null) {
color: Theme.of(context).colorScheme.surface, return const Center(
child: FutureBuilder( child: CircularProgressIndicator(),
future: getDetail(), );
builder: (context, snapshot) { }
if (!snapshot.hasData || snapshot.data == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostItem( child: LoadingIndicator(isActive: _isBusy),
item: item!, ),
isClickable: false, SliverToBoxAdapter(
isOverrideEmbedClickable: true, child: PostItem(
isFullDate: true, key: ValueKey(_item),
isFullContent: true, item: _item!,
isShowReply: false, isClickable: false,
isContentSelectable: true, isOverrideEmbedClickable: true,
isFullDate: true,
isShowReply: false,
isContentSelectable: true,
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
)
: EdgeInsets.zero,
onTapMore: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(
item: _item!,
noReact: true,
), ),
), ).then((value) {
SliverToBoxAdapter( if (value is Future) {
child: const Divider(thickness: 0.3, height: 1) value.then((_) {
.paddingOnly(top: 4), _getDetail();
), });
SliverToBoxAdapter( } else if (value != null) {
child: Align( _getDetail();
alignment: Alignment.centerLeft, }
child: Text( });
'postReplies'.tr, },
style: Theme.of(context).textTheme.headlineSmall, ),
).paddingOnly(left: 24, right: 24, top: 16), ),
), SliverToBoxAdapter(
), child: const Divider(thickness: 0.3, height: 1).paddingOnly(
PostReplyList(item: item!), top: 8,
SliverToBoxAdapter( ),
child: SizedBox(height: MediaQuery.of(context).padding.bottom), ),
), SliverToBoxAdapter(
], child: Align(
); alignment: Alignment.centerLeft,
}, child: Text(
), 'postReplies'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 16),
),
),
PostReplyList(
item: _item!,
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
)
: EdgeInsets.zero,
),
SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.bottom),
),
],
); );
} }
} }

View File

@ -16,9 +16,11 @@ import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/markdown_text_content.dart'; import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
import 'package:solian/widgets/root_container.dart';
class PostPublishArguments { class PostPublishArguments {
final Post? edit; final Post? edit;
@ -151,8 +153,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
) )
]; ];
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
@ -182,7 +183,10 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerLow, tileColor: Theme.of(context)
.colorScheme
.surfaceContainerLow
.withOpacity(0.5),
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -271,118 +275,71 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
), ),
], ],
), ),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: Row( child: DefaultTabController(
crossAxisAlignment: CrossAxisAlignment.start, length: 2,
children: [ child: AppTheme.isLargeScreen(context)
Expanded( ? Row(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: ListView( child: _PostEditorTextField(
children: [ focusNode: _contentFocusNode,
Container( controller: _editorController,
padding: const EdgeInsets.symmetric( onUpdate: () => setState(() {}),
horizontal: 16, ),
vertical: 8, ),
), const VerticalDivider(width: 0.3, thickness: 0.3)
child: TextField( .paddingSymmetric(horizontal: 16),
maxLines: null, Expanded(
autofocus: true, child: SingleChildScrollView(
autocorrect: true, padding:
keyboardType: TextInputType.multiline, const EdgeInsets.only(top: 12, bottom: 64),
controller: child: MarkdownTextContent(
_editorController.contentController, isAutoWarp: _editorController.mode.value == 0,
focusNode: _contentFocusNode, content:
decoration: InputDecoration.collapsed( _editorController.contentController.text,
hintText: 'postContentPlaceholder'.tr, parentId: 'post-editor-preview',
), ).paddingOnly(right: 16),
onTapOutside: (_) => FocusManager ),
.instance.primaryFocus ),
?.unfocus(), ],
), )
), : Column(
const Gap(120) children: [
TabBar(
tabs: [
const Tab(icon: Icon(Icons.edit)),
const Tab(icon: Icon(Icons.preview)),
], ],
), ),
), Expanded(
Obx(() { child: TabBarView(
final textStyle = TextStyle(
fontSize: 12,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.75),
);
final showFactors = [
_editorController.isRestoreFromLocal.value,
_editorController.lastSaveTime.value != null,
];
final doShow = showFactors.any((x) => x);
return Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 16,
),
child: Row(
children: [ children: [
if (showFactors[0]) _PostEditorTextField(
Text('postRestoreFromLocal'.tr, focusNode: _contentFocusNode,
style: textStyle) controller: _editorController,
.paddingOnly(right: 4), onUpdate: () => setState(() {}),
if (showFactors[0]) ),
InkWell( SingleChildScrollView(
child: Text('clear'.tr, style: textStyle), padding: const EdgeInsets.only(
onTap: () { top: 12,
_editorController.localClear(); bottom: 64,
_editorController.currentClear();
setState(() {});
},
),
if (showFactors.where((x) => x).length > 1)
Text(
'·',
style: textStyle,
).paddingSymmetric(horizontal: 8),
if (showFactors[1])
Text(
'postAutoSaveAt'.trParams({
'date': DateFormat('HH:mm:ss').format(
_editorController.lastSaveTime.value ??
DateTime.now(),
)
}),
style: textStyle,
), ),
child: MarkdownTextContent(
isAutoWarp:
_editorController.mode.value == 0,
content: _editorController
.contentController.text,
parentId: 'post-editor-preview',
).paddingOnly(left: 16, right: 16),
)
], ],
), ),
) ),
.animate( ],
key: const Key('post-editor-hint-animation'),
target: doShow ? 1 : 0,
)
.fade(curve: Curves.easeInOut, duration: 300.ms);
}),
],
),
),
if (AppTheme.isLargeScreen(context))
const VerticalDivider(width: 0.3, thickness: 0.3)
.paddingSymmetric(
horizontal: 16,
),
if (AppTheme.isLargeScreen(context))
Expanded(
child: SingleChildScrollView(
child: MarkdownTextContent(
isAutoWarp: _editorController.mode.value == 0,
content: _editorController.contentController.text,
parentId: 'post-editor-preview',
).paddingOnly(top: 12, right: 16),
), ),
),
],
), ),
), ),
Material( Material(
@ -391,6 +348,26 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Divider(thickness: 0.3, height: 0.3), const Divider(thickness: 0.3, height: 0.3),
SizedBox(
height: 40,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: MarkdownToolbar(
width: 38,
height: 38,
iconSize: 20,
spacing: 8,
hideImage: true,
useIncludedTextField: false,
backgroundColor: Theme.of(context).colorScheme.surface,
iconColor: Theme.of(context).colorScheme.onSurface,
controller: _editorController.contentController,
focusNode: _contentFocusNode,
borderRadius:
const BorderRadius.all(Radius.circular(20)),
).paddingSymmetric(horizontal: 12),
),
).paddingOnly(top: 12),
SizedBox( SizedBox(
height: 56, height: 56,
child: ListView( child: ListView(
@ -520,7 +497,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
top: -4, top: -4,
end: -6, end: -6,
), ),
child: const Icon(Icons.preview), child: const Icon(Icons.wallpaper),
); );
}), }),
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
@ -547,18 +524,6 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
_editorController.editPublishDate(context); _editorController.editPublishDate(context);
}, },
), ),
MarkdownToolbar(
hideImage: true,
useIncludedTextField: false,
backgroundColor:
Theme.of(context).colorScheme.surface,
iconColor: Theme.of(context).colorScheme.onSurface,
controller: _editorController.contentController,
focusNode: _contentFocusNode,
borderRadius:
const BorderRadius.all(Radius.circular(20)),
width: 40,
).paddingSymmetric(horizontal: 2),
], ],
).paddingSymmetric(horizontal: 6, vertical: 8), ).paddingSymmetric(horizontal: 6, vertical: 8),
), ),
@ -578,3 +543,101 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
super.dispose(); super.dispose();
} }
} }
class _PostEditorTextField extends StatelessWidget {
final FocusNode focusNode;
final PostEditorController controller;
final Function onUpdate;
const _PostEditorTextField({
required this.focusNode,
required this.controller,
required this.onUpdate,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: controller.contentController,
focusNode: focusNode,
decoration: InputDecoration.collapsed(
hintText: 'postContentPlaceholder'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(120)
],
),
),
Obx(() {
final textStyle = TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
);
final showFactors = [
controller.isRestoreFromLocal.value,
controller.lastSaveTime.value != null,
];
final doShow = showFactors.any((x) => x);
return Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 16,
),
child: Row(
children: [
if (showFactors[0])
Text('postRestoreFromLocal'.tr, style: textStyle)
.paddingOnly(right: 4),
if (showFactors[0])
InkWell(
child: Text('clear'.tr, style: textStyle),
onTap: () {
controller.localClear();
controller.currentClear();
onUpdate();
},
),
if (showFactors.where((x) => x).length > 1)
Text(
'·',
style: textStyle,
).paddingSymmetric(horizontal: 8),
if (showFactors[1])
Text(
'postAutoSaveAt'.trParams({
'date': DateFormat('HH:mm:ss').format(
controller.lastSaveTime.value ?? DateTime.now(),
)
}),
style: textStyle,
),
],
),
)
.animate(
key: const Key('post-editor-hint-animation'),
target: doShow ? 1 : 0,
)
.fade(curve: Curves.easeInOut, duration: 300.ms);
}),
],
);
}
}

View File

@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/posts/post_list.dart';
import '../../models/post.dart';
class PostSearchScreen extends StatefulWidget {
final String? tag;
final String? category;
const PostSearchScreen({super.key, this.tag, this.category});
@override
State<PostSearchScreen> createState() => _PostSearchScreenState();
}
class _PostSearchScreenState extends State<PostSearchScreen> {
int? _totalCount;
Duration? _lastTook;
final TextEditingController _probeController = TextEditingController();
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
late bool _isBusy = widget.tag != null || widget.category != null;
_searchPosts(int pageKey) async {
if (widget.tag == null &&
widget.category == null &&
_probeController.text.isEmpty) {
_pagingController.appendLastPage([]);
return;
}
if (!_isBusy) {
setState(() => _isBusy = true);
}
if (pageKey == 0) {
_pagingController.itemList?.clear();
_pagingController.nextPageKey = 0;
}
final PostProvider posts = Get.find();
Stopwatch stopwatch = new Stopwatch()..start();
Response resp;
try {
if (_probeController.text.isEmpty) {
resp = await posts.listPost(
pageKey,
tag: widget.tag,
category: widget.category,
);
} else {
resp = await posts.searchPost(
_probeController.text,
pageKey,
tag: widget.tag,
category: widget.category,
);
}
} catch (e) {
_pagingController.error = e;
return;
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
_pagingController.appendLastPage(parsed);
}
stopwatch.stop();
_totalCount = result.count;
_lastTook = stopwatch.elapsed;
setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_searchPosts);
}
@override
void dispose() {
_probeController.dispose();
_pagingController.dispose();
super.dispose();
}
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
if (widget.tag != null)
ListTile(
leading: const Icon(Icons.label),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
tileColor: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
title: Text('postSearchWithTag'.trParams({'key': widget.tag!})),
),
if (widget.category != null)
ListTile(
leading: const Icon(Icons.category),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
tileColor: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
title: Text('postSearchWithCategory'.trParams({
'key': widget.category!,
})),
),
Container(
color: Theme.of(context)
.colorScheme
.secondaryContainer
.withOpacity(0.5),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: TextField(
controller: _probeController,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: 'search'.tr,
),
onSubmitted: (_) {
_searchPosts(0);
},
),
),
LoadingIndicator(isActive: _isBusy),
if (_totalCount != null || _lastTook != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
child: Row(
children: [
Icon(
Icons.summarize_outlined,
size: 16,
color: _unFocusColor,
),
const Gap(4),
if (_totalCount != null)
Text(
'searchResult'.trParams({
'count': _totalCount!.toString(),
}),
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
),
const Gap(4),
if (_lastTook != null)
Text(
'searchTook'.trParams({
'time':
'${(_lastTook!.inMilliseconds / 1000).toStringAsFixed(3)}s',
}),
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
),
],
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
ControlledPostListWidget(
controller: _pagingController,
onUpdate: () => _pagingController.refresh(),
),
SliverGap(MediaQuery.of(context).padding.bottom),
],
),
),
),
],
),
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
@ -15,6 +14,8 @@ import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/auto_cache_image.dart'; import 'package:solian/widgets/auto_cache_image.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
class RealmListScreen extends StatefulWidget { class RealmListScreen extends StatefulWidget {
@ -58,8 +59,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
@ -93,12 +93,13 @@ class _RealmListScreenState extends State<RealmListScreen> {
return Column( return Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: CenteredContainer( child: CenteredContainer(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => _getRealms(), onRefresh: () => _getRealms(),
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16),
itemCount: _realms.length, itemCount: _realms.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final element = _realms[index]; final element = _realms[index];
@ -155,7 +156,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
size: 18, size: 18,
), ),
) )
: AccountAvatar( : AttachedCircleAvatar(
content: element.avatar!, content: element.avatar!,
bgColor: Theme.of(context).colorScheme.primary, bgColor: Theme.of(context).colorScheme.primary,
), ),

View File

@ -7,6 +7,7 @@ import 'package:solian/router.dart';
import 'package:solian/screens/realms/realm_organize.dart'; import 'package:solian/screens/realms/realm_organize.dart';
import 'package:solian/widgets/realms/realm_deletion.dart'; import 'package:solian/widgets/realms/realm_deletion.dart';
import 'package:solian/widgets/realms/realm_member.dart'; import 'package:solian/widgets/realms/realm_member.dart';
import 'package:solian/widgets/root_container.dart';
class RealmDetailScreen extends StatefulWidget { class RealmDetailScreen extends StatefulWidget {
final String alias; final String alias;
@ -86,61 +87,63 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
), ),
]; ];
return Column( return RootContainer(
children: [ child: Column(
Padding( children: [
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), Padding(
child: Row( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [ child: Row(
const CircleAvatar( children: [
radius: 28, const CircleAvatar(
backgroundColor: Colors.teal, radius: 28,
child: Icon(Icons.group, color: Colors.white), backgroundColor: Colors.teal,
), child: Icon(Icons.group, color: Colors.white),
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.realm.name,
style: Theme.of(context).textTheme.bodyLarge),
Text(widget.realm.description,
style: Theme.of(context).textTheme.bodySmall),
Text(
'#${widget.realm.id.toString().padLeft(8, '0')} · ${widget.realm.alias}',
style: const TextStyle(fontSize: 11),
),
],
), ),
) const Gap(16),
], Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.realm.name,
style: Theme.of(context).textTheme.bodyLarge),
Text(widget.realm.description,
style: Theme.of(context).textTheme.bodySmall),
Text(
'#${widget.realm.id.toString().padLeft(8, '0')} · ${widget.realm.alias}',
style: const TextStyle(fontSize: 11),
),
],
),
)
],
),
), ),
), const Divider(thickness: 0.3),
const Divider(thickness: 0.3), Expanded(
Expanded( child: ListView(
child: ListView( children: [
children: [ ListTile(
ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: const Icon(Icons.supervisor_account),
leading: const Icon(Icons.supervisor_account), trailing: const Icon(Icons.chevron_right),
trailing: const Icon(Icons.chevron_right), title: Text('realmMembers'.tr),
title: Text('realmMembers'.tr), onTap: () => showMemberList(),
onTap: () => showMemberList(), ),
), ...(_isOwned ? ownerActions : List.empty()),
...(_isOwned ? ownerActions : List.empty()), const Divider(thickness: 0.3),
const Divider(thickness: 0.3), ListTile(
ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: _isOwned
leading: _isOwned ? const Icon(Icons.delete)
? const Icon(Icons.delete) : const Icon(Icons.exit_to_app),
: const Icon(Icons.exit_to_app), title: Text(_isOwned ? 'delete'.tr : 'leave'.tr),
title: Text(_isOwned ? 'delete'.tr : 'leave'.tr), onTap: () => promptLeaveChannel(),
onTap: () => promptLeaveChannel(), ),
), ],
], ),
), ),
), ],
], ),
); );
} }
} }

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart'; import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@ -13,6 +12,8 @@ import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class RealmOrganizeArguments { class RealmOrganizeArguments {
@ -189,8 +190,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
@ -208,7 +208,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
top: false, top: false,
child: Column( child: Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
if (widget.edit != null) if (widget.edit != null)
MaterialBanner( MaterialBanner(
leading: const Icon(Icons.edit), leading: const Icon(Icons.edit),

View File

@ -16,6 +16,7 @@ import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/channel/channel_list.dart'; import 'package:solian/widgets/channel/channel_list.dart';
import 'package:solian/widgets/posts/post_list.dart'; import 'package:solian/widgets/posts/post_list.dart';
import 'package:solian/widgets/root_container.dart';
class RealmViewScreen extends StatefulWidget { class RealmViewScreen extends StatefulWidget {
final String alias; final String alias;
@ -86,8 +87,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: DefaultTabController( child: DefaultTabController(
length: 2, length: 2,
child: NestedScrollView( child: NestedScrollView(

View File

@ -1,16 +1,23 @@
import 'dart:convert';
import 'dart:io';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:in_app_review/in_app_review.dart'; import 'package:in_app_review/in_app_review.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/theme.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/database/database.dart'; import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/reports/abuse_report.dart'; import 'package:solian/widgets/reports/abuse_report.dart';
class SettingScreen extends StatefulWidget { class SettingScreen extends StatefulWidget {
@ -22,6 +29,7 @@ class SettingScreen extends StatefulWidget {
class _SettingScreenState extends State<SettingScreen> { class _SettingScreenState extends State<SettingScreen> {
SharedPreferences? _prefs; SharedPreferences? _prefs;
String _docBasepath = '/';
Widget _buildCaptionHeader(String title) { Widget _buildCaptionHeader(String title) {
return Container( return Container(
@ -32,39 +40,38 @@ class _SettingScreenState extends State<SettingScreen> {
); );
} }
Widget _buildThemeColorButton(String label, Color color) { static final List<SolianThemeData> _presentTheme = [
return IconButton( SolianThemeData(
icon: Icon(Icons.circle, color: color), id: 'themeColorRed',
tooltip: label, seedColor: const Color.fromRGBO(154, 98, 91, 1),
onPressed: () { ),
context.read<ThemeSwitcher>().setTheme( SolianThemeData(
AppTheme.build( id: 'themeColorBlue',
Brightness.light, seedColor: const Color.fromRGBO(103, 96, 193, 1),
seedColor: color, ),
), SolianThemeData(
AppTheme.build( id: 'themeColorMiku',
Brightness.dark, seedColor: const Color.fromRGBO(56, 120, 126, 1),
seedColor: color, ),
), SolianThemeData(
); id: 'themeColorKagamine',
_prefs?.setInt('global_theme_color', color.value); seedColor: const Color.fromRGBO(244, 183, 63, 1),
context.clearSnackbar(); ),
context.showSnackbar('themeColorApplied'.tr); SolianThemeData(
}, id: 'themeColorLuka',
); seedColor: const Color.fromRGBO(243, 174, 218, 1),
} ),
static final List<(String, Color)> _presentTheme = [
('themeColorRed', const Color.fromRGBO(154, 98, 91, 1)),
('themeColorBlue', const Color.fromRGBO(103, 96, 193, 1)),
('themeColorMiku', const Color.fromRGBO(56, 120, 126, 1)),
('themeColorKagamine', const Color.fromRGBO(244, 183, 63, 1)),
('themeColorLuka', const Color.fromRGBO(243, 174, 218, 1)),
]; ];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
getApplicationDocumentsDirectory().then((dir) {
_docBasepath = dir.path;
if (mounted) {
setState(() {});
}
});
SharedPreferences.getInstance().then((inst) { SharedPreferences.getInstance().then((inst) {
_prefs = inst; _prefs = inst;
if (mounted) { if (mounted) {
@ -75,163 +82,258 @@ class _SettingScreenState extends State<SettingScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return ListView(
color: Theme.of(context).colorScheme.surface, children: [
child: ListView( _buildCaptionHeader('theme'.tr),
children: [ ListTile(
_buildCaptionHeader('themeColor'.tr), leading: const Icon(Icons.palette),
SizedBox( contentPadding: const EdgeInsets.symmetric(horizontal: 22),
height: 56, title: Text('globalTheme'.tr),
child: ListView( trailing: DropdownButtonHideUnderline(
scrollDirection: Axis.horizontal, child: DropdownButton2<SolianThemeData>(
children: _presentTheme isExpanded: true,
.map((x) => _buildThemeColorButton(x.$1, x.$2)) hint: Text(
.toList(), 'theme'.tr,
).paddingSymmetric(horizontal: 12, vertical: 8), style: TextStyle(
), fontSize: 14,
_buildCaptionHeader('notification'.tr), color: Theme.of(context).hintColor,
Tooltip( ),
message: 'settingsNotificationBgServiceDesc'.tr,
child: CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
secondary: const Icon(Icons.system_security_update_warning),
enabled: PlatformInfo.isAndroid,
title: Text('settingsNotificationBgService'.tr),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('holdToSeeDetail'.tr),
Text(
'needRestartToApply'.tr,
style: const TextStyle(fontWeight: FontWeight.bold),
)
],
), ),
value: items: _presentTheme
_prefs?.getBool('service_background_notification') ?? false, .map((SolianThemeData item) =>
onChanged: (value) { DropdownMenuItem<SolianThemeData>(
_prefs value: item,
?.setBool('service_background_notification', value ?? false) child: Row(
.then((_) { crossAxisAlignment: CrossAxisAlignment.center,
setState(() {}); children: [
}); Icon(Icons.circle, color: item.seedColor),
const Gap(8),
Expanded(
child: Text(
item.id.tr,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
),
),
),
],
),
))
.toList(),
value: (_prefs?.containsKey('global_theme') ?? false)
? SolianThemeData.fromJson(
jsonDecode(_prefs!.getString('global_theme')!),
)
: null,
onChanged: (SolianThemeData? value) {
context.read<ThemeSwitcher>().setThemeData(value);
setState(() {});
}, },
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.symmetric(horizontal: 8),
height: 40,
width: 140,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
), ),
), ),
_buildCaptionHeader('update'.tr), ),
CheckboxListTile( CheckboxListTile(
secondary: const Icon(Icons.military_tech),
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
title: Text('agedTheme'.tr),
subtitle: Text('agedThemeDesc'.tr),
value: _prefs?.getBool('aged_theme') ?? false,
onChanged: (value) {
if (value != null) {
context.read<ThemeSwitcher>().setAgedTheme(value);
}
setState(() {});
},
),
if (!PlatformInfo.isWeb)
ListTile(
leading: const Icon(Icons.wallpaper),
contentPadding: const EdgeInsets.only(left: 22, right: 31),
title: Text('appBackgroundImage'.tr),
subtitle: Text('appBackgroundImageDesc'.tr),
trailing: File('$_docBasepath/app_background_image').existsSync()
? const Icon(Icons.check_box)
: const Icon(Icons.check_box_outline_blank),
onTap: () async {
if (File('$_docBasepath/app_background_image').existsSync()) {
File('$_docBasepath/app_background_image').deleteSync();
} else {
final image = await ImagePicker().pickImage(
source: ImageSource.gallery,
);
if (image == null) return;
await File(image.path)
.copy('$_docBasepath/app_background_image');
}
setState(() {});
},
),
_buildCaptionHeader('notification'.tr),
Tooltip(
message: 'settingsNotificationBgServiceDesc'.tr,
child: CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 22), contentPadding: const EdgeInsets.symmetric(horizontal: 22),
secondary: const Icon(Icons.sync_alt), secondary: const Icon(Icons.system_security_update_warning),
title: Text('updateCheckStrictly'.tr), enabled: PlatformInfo.isAndroid,
subtitle: Text('updateCheckStrictlyDesc'.tr), title: Text('settingsNotificationBgService'.tr),
value: _prefs?.getBool('check_update_strictly') ?? false, subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('holdToSeeDetail'.tr),
Text(
'needRestartToApply'.tr,
style: const TextStyle(fontWeight: FontWeight.bold),
)
],
),
value: _prefs?.getBool('service_background_notification') ?? false,
onChanged: (value) { onChanged: (value) {
_prefs _prefs
?.setBool('check_update_strictly', value ?? false) ?.setBool('service_background_notification', value ?? false)
.then((_) { .then((_) {
setState(() {}); setState(() {});
}); });
}, },
), ),
Obx(() { ),
final AuthProvider auth = Get.find<AuthProvider>(); _buildCaptionHeader('update'.tr),
if (!auth.isAuthorized.value) return const SizedBox.shrink(); CheckboxListTile(
return Column( contentPadding: const EdgeInsets.symmetric(horizontal: 22),
children: [ secondary: const Icon(Icons.sync_alt),
_buildCaptionHeader('account'.tr), title: Text('updateCheckStrictly'.tr),
ListTile( subtitle: Text('updateCheckStrictlyDesc'.tr),
leading: const Icon(Icons.flag), value: _prefs?.getBool('check_update_strictly') ?? false,
trailing: const Icon(Icons.chevron_right), onChanged: (value) {
contentPadding: const EdgeInsets.symmetric(horizontal: 22), _prefs?.setBool('check_update_strictly', value ?? false).then((_) {
title: Text('reportAbuse'.tr), setState(() {});
subtitle: Text('reportAbuseDesc'.tr), });
onTap: () { },
showDialog( ),
context: context, Obx(() {
builder: (context) => const AbuseReportDialog(), final AuthProvider auth = Get.find<AuthProvider>();
); if (!auth.isAuthorized.value) return const SizedBox.shrink();
}, return Column(
), children: [
ListTile( _buildCaptionHeader('account'.tr),
leading: const Icon(Icons.person_remove), ListTile(
trailing: const Icon(Icons.chevron_right), leading: const Icon(Icons.flag),
contentPadding: const EdgeInsets.symmetric(horizontal: 22), trailing: const Icon(Icons.chevron_right),
title: Text('accountDeletion'.tr), contentPadding: const EdgeInsets.symmetric(horizontal: 22),
subtitle: Text('accountDeletionDesc'.tr), title: Text('reportAbuse'.tr),
onTap: () { subtitle: Text('reportAbuseDesc'.tr),
context onTap: () {
.showSlideToConfirmDialog( showDialog(
'accountDeletionConfirm'.tr, context: context,
'accountDeletionConfirmDesc'.trParams({ builder: (context) => const AbuseReportDialog(),
'account': '@${auth.userProfile.value!['name']}', );
}), },
) ),
.then((value) async { ListTile(
if (value != true) return; leading: const Icon(Icons.person_remove),
final client = await auth.configureClient('id'); trailing: const Icon(Icons.chevron_right),
final resp = await client.post('/users/me/deletion', {}); contentPadding: const EdgeInsets.symmetric(horizontal: 22),
if (resp.statusCode != 200) { title: Text('accountDeletion'.tr),
context.showErrorDialog(RequestException(resp)); subtitle: Text('accountDeletionDesc'.tr),
} else { onTap: () {
context.showSnackbar('accountDeletionRequested'.tr); context
} .showSlideToConfirmDialog(
}); 'accountDeletionConfirm'.tr,
}, 'accountDeletionConfirmDesc'.trParams({
), 'account': '@${auth.userProfile.value!['name']}',
], }),
); )
}), .then((value) async {
_buildCaptionHeader('more'.tr), if (value != true) return;
ListTile( final client = await auth.configureClient('id');
leading: const Icon(Icons.delete_sweep), final resp = await client.post('/users/me/deletion', {});
trailing: const Icon(Icons.chevron_right), if (resp.statusCode != 200) {
subtitle: FutureBuilder( context.showErrorDialog(RequestException(resp));
future: AppDatabase.getDatabaseSize(), } else {
builder: (context, snapshot) { context.showSnackbar('accountDeletionRequested'.tr);
if (!snapshot.hasData) { }
return Text('localDatabaseSize'.trParams( });
{'size': 'unknown'.tr}, },
)); ),
} ],
);
}),
_buildCaptionHeader('performance'.tr),
CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
secondary: const Icon(Icons.message),
title: Text('animatedMessageList'.tr),
subtitle: Text('animatedMessageListDesc'.tr),
value: _prefs?.getBool('non_animated_message_list') ?? false,
onChanged: (value) {
_prefs
?.setBool('non_animated_message_list', value ?? false)
.then((_) {
setState(() {});
});
},
),
_buildCaptionHeader('more'.tr),
ListTile(
leading: const Icon(Icons.delete_sweep),
trailing: const Icon(Icons.chevron_right),
subtitle: FutureBuilder(
future: AppDatabase.getDatabaseSize(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text('localDatabaseSize'.trParams( return Text('localDatabaseSize'.trParams(
{'size': snapshot.data!.formatBytes()}, {'size': 'unknown'.tr},
)); ));
}, }
), return Text('localDatabaseSize'.trParams(
contentPadding: const EdgeInsets.symmetric(horizontal: 22), {'size': snapshot.data!.formatBytes()},
title: Text('localDatabaseWipe'.tr), ));
onTap: () {
AppDatabase.removeDatabase().then((_) {
setState(() {});
});
}, },
), ),
if (PlatformInfo.canRateTheApp) contentPadding: const EdgeInsets.symmetric(horizontal: 22),
ListTile( title: Text('localDatabaseWipe'.tr),
leading: const Icon(Icons.star), onTap: () {
trailing: const Icon(Icons.chevron_right), AppDatabase.removeDatabase().then((_) {
contentPadding: const EdgeInsets.symmetric(horizontal: 22), setState(() {});
title: Text('rateTheApp'.tr), });
subtitle: Text('rateTheAppDesc'.tr), },
onTap: () { ),
final inAppReview = InAppReview.instance; if (PlatformInfo.canRateTheApp)
inAppReview.openStoreListing(
appStoreId: '6499032345',
);
},
),
ListTile( ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.star),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 22), contentPadding: const EdgeInsets.symmetric(horizontal: 22),
title: Text('about'.tr), title: Text('rateTheApp'.tr),
subtitle: Text('rateTheAppDesc'.tr),
onTap: () { onTap: () {
AppRouter.instance.pushNamed('about'); final inAppReview = InAppReview.instance;
inAppReview.openStoreListing(
appStoreId: '6499032345',
);
}, },
), ),
], ListTile(
), leading: const Icon(Icons.info_outline),
trailing: const Icon(Icons.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
title: Text('about'.tr),
onTap: () {
AppRouter.instance.pushNamed('about');
},
),
],
); );
} }
} }

View File

@ -43,21 +43,34 @@ class RootShell extends StatelessWidget {
final showRailNavigation = AppTheme.isLargeScreen(context); final showRailNavigation = AppTheme.isLargeScreen(context);
final destNames = AppNavigation.destinations.map((x) => x.page).toList(); final destNames = [
'postDetail',
...AppNavigation.destinations.map((x) => x.page),
];
final showBottomNavigation = final showBottomNavigation =
destNames.contains(routeName) && !showRailNavigation; destNames.contains(routeName) && !showRailNavigation;
return Scaffold( return Scaffold(
key: rootScaffoldKey, key: rootScaffoldKey,
backgroundColor: Theme.of(context).colorScheme.surface,
bottomNavigationBar: showBottomNavigation bottomNavigationBar: showBottomNavigation
? AppNavigationBottom( ? AppNavigationBottom(
initialIndex: destNames.indexOf(routeName ?? 'page'), initialIndex: AppNavigation.destinations
.map((x) => x.page)
.toList()
.indexOf(routeName ?? 'page'),
) )
: null, : null,
body: AppTheme.isLargeScreen(context) body: AppTheme.isLargeScreen(context)
? Row( ? Row(
children: [ children: [
if (showRailNavigation) const AppNavigationRail(), if (showRailNavigation)
AppNavigationRail(
initialIndex: AppNavigation.destinations
.map((x) => x.page)
.toList()
.indexOf(routeName ?? 'page'),
),
if (showRailNavigation) if (showRailNavigation)
const VerticalDivider( const VerticalDivider(
width: 0.3, width: 0.3,

View File

@ -5,10 +5,12 @@ import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/root_container.dart';
class TitleShell extends StatelessWidget { class TitleShell extends StatelessWidget {
final bool showAppBar; final bool showAppBar;
final bool isCenteredTitle; final bool isCenteredTitle;
final bool isResponsive;
final String? title; final String? title;
final GoRouterState? state; final GoRouterState? state;
final Widget child; final Widget child;
@ -20,13 +22,14 @@ class TitleShell extends StatelessWidget {
this.state, this.state,
this.showAppBar = true, this.showAppBar = true,
this.isCenteredTitle = false, this.isCenteredTitle = false,
this.isResponsive = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(state != null || title != null); assert(state != null || title != null);
return Scaffold( final widget = Scaffold(
appBar: showAppBar appBar: showAppBar
? AppBar( ? AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
@ -45,5 +48,11 @@ class TitleShell extends StatelessWidget {
: null, : null,
body: child, body: child,
); );
if (isResponsive) {
return ResponsiveRootContainer(child: widget);
} else {
return RootContainer(child: widget);
}
} }
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:solian/models/theme.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
abstract class AppTheme { abstract class AppTheme {
@ -41,6 +42,10 @@ abstract class AppTheme {
snackBarTheme: const SnackBarThemeData( snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
scaffoldBackgroundColor: Colors.transparent,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
),
fontFamily: 'Comfortaa', fontFamily: 'Comfortaa',
fontFamilyFallback: [ fontFamilyFallback: [
'NotoSansSC', 'NotoSansSC',
@ -55,4 +60,37 @@ abstract class AppTheme {
), ),
); );
} }
static ThemeData buildFromData(
Brightness brightness,
SolianThemeData data, {
bool useMaterial3 = true,
}) {
return ThemeData(
brightness: brightness,
useMaterial3: useMaterial3,
colorScheme: ColorScheme.fromSeed(
brightness: brightness,
seedColor: data.seedColor,
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
scaffoldBackgroundColor: Colors.transparent,
appBarTheme: const AppBarTheme(backgroundColor: Colors.transparent),
fontFamily: data.fontFamily ?? 'Comfortaa',
fontFamilyFallback: data.fontFamilyFallback ??
[
'NotoSansSC',
'NotoSansHK',
'NotoSansJP',
if (PlatformInfo.isWeb) 'NotoSansEmoji',
],
typography: Typography.material2021(
colorScheme: brightness == Brightness.light
? const ColorScheme.light()
: const ColorScheme.dark(),
),
);
}
} }

View File

@ -1,15 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/auto_cache_image.dart'; import 'package:solian/widgets/auto_cache_image.dart';
class AccountAvatar extends StatelessWidget { class AttachedCircleAvatar extends StatelessWidget {
final dynamic content; final dynamic content;
final Color? bgColor; final Color? bgColor;
final Color? feColor; final Color? feColor;
final double? radius; final double? radius;
final Widget? fallbackWidget; final Widget? fallbackWidget;
const AccountAvatar({ const AttachedCircleAvatar({
super.key, super.key,
required this.content, required this.content,
this.bgColor, this.bgColor,
@ -39,7 +40,7 @@ class AccountAvatar extends StatelessWidget {
child: isEmpty child: isEmpty
? (fallbackWidget ?? ? (fallbackWidget ??
Icon( Icon(
Icons.account_circle, Icons.image,
size: radius != null ? radius! * 1.2 : 24, size: radius != null ? radius! * 1.2 : 24,
color: feColor, color: feColor,
)) ))
@ -48,6 +49,54 @@ class AccountAvatar extends StatelessWidget {
} }
} }
class AccountAvatar extends StatelessWidget {
final dynamic content;
final String username;
final Color? bgColor;
final Color? feColor;
final double? radius;
final Widget? fallbackWidget;
const AccountAvatar({
super.key,
required this.content,
required this.username,
this.bgColor,
this.feColor,
this.radius,
this.fallbackWidget,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: AttachedCircleAvatar(
content: content,
bgColor: bgColor,
feColor: feColor,
radius: radius,
fallbackWidget: (fallbackWidget ??
Icon(
Icons.account_circle,
size: radius != null ? radius! * 1.2 : 24,
color: feColor,
)),
),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
name: username,
),
);
},
);
}
}
class AccountProfileImage extends StatelessWidget { class AccountProfileImage extends StatelessWidget {
final dynamic content; final dynamic content;
final BoxFit fit; final BoxFit fit;

View File

@ -84,7 +84,7 @@ class AccountHeadingWidget extends StatelessWidget {
Positioned( Positioned(
bottom: -30, bottom: -30,
left: 32, left: 32,
child: AccountAvatar(content: avatar, radius: 40), child: AttachedCircleAvatar(content: avatar, radius: 40),
), ),
], ],
), ),

View File

@ -89,8 +89,7 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
return SizedBox( return SizedBox(
height: MediaQuery.of(context).size.height * 0.75, height: MediaQuery.of(context).size.height * 0.75,
child: Column( child: ListView(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AccountHeadingWidget( AccountHeadingWidget(
avatar: _userinfo!.avatar, avatar: _userinfo!.avatar,
@ -118,7 +117,7 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
const VisualDensity(horizontal: -4, vertical: -2), const VisualDensity(horizontal: -4, vertical: -2),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
AppRouter.instance.goNamed( AppRouter.instance.pushNamed(
'accountProfilePage', 'accountProfilePage',
pathParameters: {'name': _userinfo!.name}, pathParameters: {'name': _userinfo!.name},
); );

View File

@ -138,7 +138,7 @@ class _AccountSelectorState extends State<AccountSelector> {
return ListTile( return ListTile(
title: Text(element.nick), title: Text(element.nick),
subtitle: Text(element.name), subtitle: Text(element.name),
leading: AccountAvatar(content: element.avatar), leading: AttachedCircleAvatar(content: element.avatar),
trailing: widget.trailingBuilder != null trailing: widget.trailingBuilder != null
? widget.trailingBuilder!(element) ? widget.trailingBuilder!(element)
: _checkSelected(element) : _checkSelected(element)

View File

@ -23,7 +23,7 @@ class SilverRelativeList extends StatelessWidget {
title: Text(element.related.nick), title: Text(element.related.nick),
subtitle: Text(element.related.name), subtitle: Text(element.related.name),
leading: GestureDetector( leading: GestureDetector(
child: AccountAvatar(content: element.related.avatar), child: AttachedCircleAvatar(content: element.related.avatar),
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,

View File

@ -56,7 +56,7 @@ class _RelativeSelectorState extends State<RelativeSelector> {
return ListTile( return ListTile(
title: Text(element.nick), title: Text(element.nick),
subtitle: Text(element.name), subtitle: Text(element.name),
leading: AccountAvatar(content: element.avatar), leading: AttachedCircleAvatar(content: element.avatar),
trailing: widget.trailingBuilder != null trailing: widget.trailingBuilder != null
? widget.trailingBuilder!(element) ? widget.trailingBuilder!(element)
: null, : null,

View File

@ -1,28 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:solian/shells/root_shell.dart';
class AppBarLeadingButton extends StatelessWidget { class AppBarLeadingButton extends StatelessWidget {
const AppBarLeadingButton({super.key}); final bool forceBack;
static Widget? adaptive(BuildContext context) { const AppBarLeadingButton({super.key, this.forceBack = false});
final hasContent =
Navigator.canPop(context) || rootScaffoldKey.currentState!.hasDrawer; static Widget? adaptive(BuildContext context, {bool forceBack = false}) {
return hasContent ? const AppBarLeadingButton() : null; final hasContent = Navigator.canPop(context) || forceBack;
return hasContent ? AppBarLeadingButton(forceBack: forceBack) : null;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (Navigator.canPop(context)) { if (Navigator.canPop(context) || forceBack) {
return BackButton( return BackButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
); );
} }
if (rootScaffoldKey.currentState!.hasDrawer) { return const SizedBox.shrink();
return DrawerButton(
onPressed: () => rootScaffoldKey.currentState!.openDrawer(),
);
} else {
return const SizedBox.shrink();
}
} }
} }

View File

@ -21,6 +21,7 @@ import 'package:solian/providers/content/attachment.dart';
import 'package:solian/widgets/attachments/attachment_attr_editor.dart'; import 'package:solian/widgets/attachments/attachment_attr_editor.dart';
import 'package:solian/widgets/attachments/attachment_editor_thumbnail.dart'; import 'package:solian/widgets/attachments/attachment_editor_thumbnail.dart';
import 'package:solian/widgets/attachments/attachment_fullscreen.dart'; import 'package:solian/widgets/attachments/attachment_fullscreen.dart';
import 'package:solian/widgets/loading_indicator.dart';
class AttachmentEditorPopup extends StatefulWidget { class AttachmentEditorPopup extends StatefulWidget {
final String pool; final String pool;
@ -32,12 +33,14 @@ class AttachmentEditorPopup extends StatefulWidget {
final List<String>? initialAttachments; final List<String>? initialAttachments;
final void Function(String) onAdd; final void Function(String) onAdd;
final void Function(String) onRemove; final void Function(String) onRemove;
final void Function(String)? onInsert;
const AttachmentEditorPopup({ const AttachmentEditorPopup({
super.key, super.key,
required this.pool, required this.pool,
required this.onAdd, required this.onAdd,
required this.onRemove, required this.onRemove,
this.onInsert,
this.singleMode = false, this.singleMode = false,
this.imageOnly = false, this.imageOnly = false,
this.autoUpload = false, this.autoUpload = false,
@ -228,7 +231,10 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
.listMetadata(widget.initialAttachments ?? List.empty()) .listMetadata(widget.initialAttachments ?? List.empty())
.then((result) { .then((result) {
setState(() { setState(() {
_attachments = List.from(result, growable: true); _attachments = List.from(
result.where((x) => x != null),
growable: true,
);
_isBusy = false; _isBusy = false;
_isFirstTimeBusy = false; _isFirstTimeBusy = false;
}); });
@ -553,6 +559,22 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
setState(() => _attachments.removeAt(idx)); setState(() => _attachments.removeAt(idx));
}, },
), ),
if (widget.onInsert != null)
PopupMenuItem(
child: ListTile(
title: Text('insert'.tr),
leading: const Icon(Icons.insert_link),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
onTap: () {
widget.onInsert!(
'![](solink://attachments/${element.rid})',
);
Navigator.pop(context);
},
),
], ],
), ),
], ],
@ -660,7 +682,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
), ),
], ],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [

View File

@ -8,6 +8,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:gal/gal.dart'; import 'package:gal/gal.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
@ -103,9 +104,10 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final metaTextStyle = TextStyle( final metaTextStyle = GoogleFonts.roboto(
fontSize: 12, fontSize: 12,
color: _unFocusColor, color: _unFocusColor,
height: 1,
); );
return DismissiblePage( return DismissiblePage(
@ -175,7 +177,7 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
Row( Row(
children: [ children: [
IgnorePointer( IgnorePointer(
child: AccountAvatar( child: AttachedCircleAvatar(
content: widget.item.account!.avatar, content: widget.item.account!.avatar,
radius: 19, radius: 19,
), ),
@ -239,25 +241,58 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
child: Wrap( child: Wrap(
spacing: 6, spacing: 6,
children: [ children: [
Text( if (widget.item.metadata?['exif'] == null)
'#${widget.item.rid}',
style: metaTextStyle,
),
if (widget.item.metadata?['width'] != null &&
widget.item.metadata?['height'] != null)
Text( Text(
'${widget.item.metadata?['width']}x${widget.item.metadata?['height']}', '#${widget.item.rid}',
style: metaTextStyle, style: metaTextStyle,
), ),
if (widget.item.metadata?['exif']?['Model'] != null)
Text(
'shotOn'.trParams({
'device': widget.item.metadata?['exif']
?['Model']
}),
style: metaTextStyle,
).paddingOnly(right: 2),
if (widget.item.metadata?['exif']?['ShutterSpeed'] !=
null)
Text(
widget.item.metadata?['exif']?['ShutterSpeed'],
style: metaTextStyle,
).paddingOnly(right: 2),
if (widget.item.metadata?['exif']?['ISO'] != null)
Text(
'ISO${widget.item.metadata?['exif']?['ISO']}',
style: metaTextStyle,
).paddingOnly(right: 2),
if (widget.item.metadata?['exif']?['Aperture'] !=
null)
Text(
'f/${widget.item.metadata?['exif']?['Aperture']}',
style: metaTextStyle,
).paddingOnly(right: 2),
if (widget.item.metadata?['exif']?['Megapixels'] !=
null &&
widget.item.metadata?['exif']?['Model'] != null)
Text(
'${widget.item.metadata?['exif']?['Megapixels']}MP',
style: metaTextStyle,
)
else
Text(
widget.item.size.formatBytes(),
style: metaTextStyle,
),
Text(
'${widget.item.metadata?['width']}x${widget.item.metadata?['height']}',
style: metaTextStyle,
),
if (widget.item.metadata?['ratio'] != null) if (widget.item.metadata?['ratio'] != null)
Text( Text(
'${_getRatio().toPrecision(2)}', (widget.item.metadata?['ratio'] as num)
.toStringAsFixed(2),
style: metaTextStyle, style: metaTextStyle,
), ),
Text(
widget.item.size.formatBytes(),
style: metaTextStyle,
),
Text( Text(
widget.item.mimetype, widget.item.mimetype,
style: metaTextStyle, style: metaTextStyle,

View File

@ -155,11 +155,18 @@ class _AttachmentItemImage extends StatelessWidget {
), ),
if (showBadge && badge != null) if (showBadge && badge != null)
Positioned( Positioned(
right: 12, right: 8,
bottom: 8, bottom: 4,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Chip(label: Text(badge!)), child: Chip(
label: Text(badge!),
labelStyle: GoogleFonts.robotoMono(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
),
), ),
), ),
if (showHideButton && item.isMature) if (showHideButton && item.isMature)

View File

@ -1,7 +1,6 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui'; import 'dart:ui';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:dismissible_page/dismissible_page.dart'; import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/material.dart' hide CarouselController; import 'package:flutter/material.dart' hide CarouselController;
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
@ -15,27 +14,29 @@ import 'package:solian/widgets/sized_container.dart';
class AttachmentList extends StatefulWidget { class AttachmentList extends StatefulWidget {
final String parentId; final String parentId;
final List<String> attachmentsId; final List<String>? attachmentIds;
final List<Attachment>? attachments;
final bool isGrid; final bool isGrid;
final bool isColumn; final bool isColumn;
final bool isForceGrid; final bool isFullWidth;
final bool autoload; final bool autoload;
final double flatMaxHeight;
final double columnMaxWidth; final double columnMaxWidth;
final EdgeInsets? padding;
final double? width; final double? width;
final double? viewport; final double? viewport;
const AttachmentList({ const AttachmentList({
super.key, super.key,
required this.parentId, required this.parentId,
required this.attachmentsId, this.attachmentIds,
this.attachments,
this.isGrid = false, this.isGrid = false,
this.isColumn = false, this.isColumn = false,
this.isForceGrid = false, this.isFullWidth = false,
this.autoload = false, this.autoload = false,
this.flatMaxHeight = 720,
this.columnMaxWidth = 480, this.columnMaxWidth = 480,
this.padding,
this.width, this.width,
this.viewport, this.viewport,
}); });
@ -48,23 +49,24 @@ class _AttachmentListState extends State<AttachmentList> {
bool _isLoading = true; bool _isLoading = true;
bool _showMature = false; bool _showMature = false;
// ignore: unused_field
double _aspectRatio = 1; double _aspectRatio = 1;
List<Attachment?> _attachmentsMeta = List.empty(); List<Attachment?> _attachments = List.empty();
void _getMetadataList() { void _getMetadataList() {
final AttachmentProvider attach = Get.find(); final AttachmentProvider attach = Get.find();
if (widget.attachmentsId.isEmpty) { if (widget.attachmentIds?.isEmpty ?? false) {
return; return;
} else { } else {
_attachmentsMeta = List.filled(widget.attachmentsId.length, null); _attachments = List.filled(widget.attachmentIds!.length, null);
} }
attach.listMetadata(widget.attachmentsId).then((result) { attach.listMetadata(widget.attachmentIds!).then((result) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_attachmentsMeta = result; _attachments = result;
_isLoading = false; _isLoading = false;
}); });
} }
@ -76,7 +78,7 @@ class _AttachmentListState extends State<AttachmentList> {
bool isConsistent = true; bool isConsistent = true;
double? consistentValue; double? consistentValue;
int portrait = 0, square = 0, landscape = 0; int portrait = 0, square = 0, landscape = 0;
for (var entry in _attachmentsMeta) { for (var entry in _attachments) {
if (entry == null) continue; if (entry == null) continue;
if (entry.metadata?['ratio'] != null) { if (entry.metadata?['ratio'] != null) {
if (entry.metadata?['ratio'] is int) { if (entry.metadata?['ratio'] is int) {
@ -117,10 +119,9 @@ class _AttachmentListState extends State<AttachmentList> {
item: element, item: element,
parentId: widget.parentId, parentId: widget.parentId,
width: width ?? widget.width, width: width ?? widget.width,
badgeContent: '${idx + 1}/${_attachmentsMeta.length}', badgeContent: '${idx + 1}/${_attachments.length}',
showBadge: showBadge: _attachments.length > 1 && !widget.isGrid && !widget.isColumn,
_attachmentsMeta.length > 1 && !widget.isGrid && !widget.isColumn, showBorder: _attachments.length > 1,
showBorder: widget.attachmentsId.length > 1,
showMature: _showMature, showMature: _showMature,
autoload: widget.autoload, autoload: widget.autoload,
onReveal: (value) { onReveal: (value) {
@ -132,7 +133,26 @@ class _AttachmentListState extends State<AttachmentList> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_getMetadataList(); assert(widget.attachmentIds != null || widget.attachments != null);
if (widget.attachments == null) {
final AttachmentProvider attach = Get.find();
final cachedResult = attach.listMetadataFromCache(widget.attachmentIds!);
if (cachedResult.every((x) => x != null)) {
setState(() {
_attachments = cachedResult;
_isLoading = false;
});
_calculateAspectRatio();
} else {
_getMetadataList();
}
} else {
setState(() {
_attachments = widget.attachments!;
_isLoading = false;
});
_calculateAspectRatio();
}
} }
Color get _unFocusColor => Color get _unFocusColor =>
@ -140,7 +160,7 @@ class _AttachmentListState extends State<AttachmentList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.attachmentsId.isEmpty) { if (widget.attachmentIds?.isEmpty ?? widget.attachments!.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@ -153,9 +173,7 @@ class _AttachmentListState extends State<AttachmentList> {
color: _unFocusColor, color: _unFocusColor,
).paddingOnly(right: 5), ).paddingOnly(right: 5),
Text( Text(
'attachmentHint'.trParams( 'attachmentHint'.trParams({'count': _attachments.toString()}),
{'count': widget.attachmentsId.length.toString()},
),
style: TextStyle(color: _unFocusColor, fontSize: 12), style: TextStyle(color: _unFocusColor, fontSize: 12),
) )
], ],
@ -165,21 +183,90 @@ class _AttachmentListState extends State<AttachmentList> {
.fadeIn(duration: 1250.ms); .fadeIn(duration: 1250.ms);
} }
const radius = BorderRadius.all(Radius.circular(8));
if (widget.isFullWidth && _attachments.length == 1) {
final element = _attachments.first;
final isImage = element!.mimetype.split('/').firstOrNull == 'image';
double ratio =
element.metadata?['ratio']?.toDouble() ?? (isImage ? 1 : 16 / 9);
return Container(
width: MediaQuery.of(context).size.width,
constraints: BoxConstraints(
maxHeight: 640,
),
child: AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: _buildEntry(element, 0),
),
),
);
}
final isNotPureImage = _attachments.any(
(x) => x?.mimetype.split('/').firstOrNull != 'image',
);
if (widget.isGrid && !isNotPureImage) {
return GridView.builder(
padding: EdgeInsets.zero,
primary: false,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: math.min(3, _attachments.length),
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
),
itemCount: _attachments.length,
itemBuilder: (context, idx) {
final element = _attachments[idx];
return Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: radius,
),
child: ClipRRect(
borderRadius: radius,
child: _buildEntry(element, idx),
),
);
},
);
}
if (widget.isColumn) { if (widget.isColumn) {
var idx = 0; var idx = 0;
const radius = BorderRadius.all(Radius.circular(8));
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: widget.attachmentsId.map((x) { children: _attachments.map((x) {
final element = _attachmentsMeta[idx]; final element = _attachments[idx];
idx++; idx++;
if (element == null) return const SizedBox.shrink(); if (element == null) return const SizedBox.shrink();
double ratio = element.metadata?['ratio']?.toDouble() ?? 16 / 9; final isImage = element.mimetype.split('/').firstOrNull == 'image';
double ratio =
element.metadata?['ratio']?.toDouble() ?? (isImage ? 1 : 16 / 9);
return Container( return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth, maxWidth: widget.columnMaxWidth,
maxHeight: 640, maxHeight: 640,
@ -188,6 +275,10 @@ class _AttachmentListState extends State<AttachmentList> {
aspectRatio: ratio, aspectRatio: ratio,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
border: Border.all( border: Border.all(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
width: 1, width: 1,
@ -205,68 +296,52 @@ class _AttachmentListState extends State<AttachmentList> {
); );
} }
final isNotPureImage = _attachmentsMeta.any( return Container(
(x) => x?.mimetype.split('/').firstOrNull != 'image', constraints: BoxConstraints(
); maxHeight: 320,
if (widget.isGrid && (widget.isForceGrid || !isNotPureImage)) { ),
const radius = BorderRadius.all(Radius.circular(8)); child: ListView.separated(
return GridView.builder( padding: widget.padding,
padding: EdgeInsets.zero, scrollDirection: Axis.horizontal,
primary: false,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true, shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( itemCount: _attachments.length,
crossAxisCount: math.min(3, widget.attachmentsId.length),
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
),
itemCount: widget.attachmentsId.length,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
final element = _attachmentsMeta[idx]; final element = _attachments[idx];
if (element == null) const SizedBox.shrink();
final isImage = element!.mimetype.split('/').firstOrNull == 'image';
double ratio =
element.metadata?['ratio']?.toDouble() ?? (isImage ? 1 : 16 / 9);
return Container( return Container(
decoration: BoxDecoration( constraints: BoxConstraints(
color: Theme.of(context).colorScheme.surfaceContainerHigh, maxWidth: math.min(
border: Border.all( widget.columnMaxWidth,
color: Theme.of(context).dividerColor, MediaQuery.of(context).size.width -
width: 1, (widget.padding?.horizontal ?? 0),
), ),
borderRadius: radius,
), ),
child: ClipRRect( child: AspectRatio(
borderRadius: radius, aspectRatio: ratio,
child: _buildEntry(element, idx), child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: radius,
),
child: ClipRRect(
borderRadius: radius,
child: _buildEntry(element, idx),
),
),
), ),
); );
}, },
).paddingSymmetric(horizontal: 24); separatorBuilder: (context, _) => const Gap(8),
}
return Container(
width: MediaQuery.of(context).size.width,
constraints: BoxConstraints(
maxHeight: widget.flatMaxHeight,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
border: Border.symmetric(
horizontal: BorderSide(
width: 0.3,
color: Theme.of(context).dividerColor,
),
),
),
child: CarouselSlider.builder(
options: CarouselOptions(
aspectRatio: _aspectRatio,
viewportFraction:
widget.viewport ?? (widget.attachmentsId.length > 1 ? 0.95 : 1),
enableInfiniteScroll: false,
),
itemCount: _attachmentsMeta.length,
itemBuilder: (context, idx, _) {
final element = _attachmentsMeta[idx];
return _buildEntry(element, idx);
},
), ),
); );
} }
@ -319,6 +394,7 @@ class AttachmentListEntry extends StatelessWidget {
width: width ?? MediaQuery.of(context).size.width, width: width ?? MediaQuery.of(context).size.width,
height: height, height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.transparent,
border: showBorder border: showBorder
? Border.symmetric( ? Border.symmetric(
vertical: BorderSide( vertical: BorderSide(
@ -344,11 +420,13 @@ class AttachmentListEntry extends StatelessWidget {
}, },
), ),
if (item!.isMature && !showMature) if (item!.isMature && !showMature)
BackdropFilter( ClipRect(
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), child: BackdropFilter(
child: Container( filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
decoration: BoxDecoration( child: Container(
color: Colors.black.withOpacity(0.5), decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
),
), ),
), ),
), ),

View File

@ -38,11 +38,13 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
Future<void> _loadLastMessages() async { Future<void> _loadLastMessages() async {
final messages = await _eventController.src.getLastInAllChannels(); final messages = await _eventController.src.getLastInAllChannels();
setState(() { if (mounted) {
_lastMessages = messages setState(() {
.map((k, v) => MapEntry(k, v.firstOrNull)) _lastMessages = messages
.cast<int, LocalMessageEventTableData>(); .map((k, v) => MapEntry(k, v.firstOrNull))
}); .cast<int, LocalMessageEventTableData>();
});
}
} }
@override @override
@ -205,7 +207,7 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
item.members!.where((e) => e.account.id != widget.selfId).firstOrNull; item.members!.where((e) => e.account.id != widget.selfId).firstOrNull;
if (item.type == 1 && otherside != null) { if (item.type == 1 && otherside != null) {
final avatar = AccountAvatar( final avatar = AttachedCircleAvatar(
content: otherside.account.avatar, content: otherside.account.avatar,
radius: 20, radius: 20,
bgColor: Theme.of(context).colorScheme.primary, bgColor: Theme.of(context).colorScheme.primary,
@ -241,7 +243,7 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
elevation: 8, elevation: 8,
), ),
badgeContent: AccountAvatar( badgeContent: AttachedCircleAvatar(
content: item.realm?.avatar, content: item.realm?.avatar,
radius: 10, radius: 10,
fallbackWidget: const Icon( fallbackWidget: const Icon(

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -8,6 +7,7 @@ import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart'; import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/account/relative_select.dart'; import 'package:solian/widgets/account/relative_select.dart';
import 'package:solian/widgets/loading_indicator.dart';
class ChannelMemberListPopup extends StatefulWidget { class ChannelMemberListPopup extends StatefulWidget {
final Channel channel; final Channel channel;
@ -131,7 +131,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
'channelMembers'.tr, 'channelMembers'.tr,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh, tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 20), contentPadding: const EdgeInsets.symmetric(horizontal: 20),
@ -152,7 +152,8 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
title: Text(element.account.nick), title: Text(element.account.nick),
subtitle: Text(element.account.name), subtitle: Text(element.account.name),
leading: GestureDetector( leading: GestureDetector(
child: AccountAvatar(content: element.account.avatar), child:
AttachedCircleAvatar(content: element.account.avatar),
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,

View File

@ -74,7 +74,7 @@ class _NoContentWidgetState extends State<NoContentWidget>
), ),
) )
], ],
child: AccountAvatar( child: AttachedCircleAvatar(
content: widget.userinfo!.avatar, content: widget.userinfo!.avatar,
bgColor: Colors.transparent, bgColor: Colors.transparent,
radius: radius, radius: radius,

View File

@ -78,7 +78,7 @@ class ChatEvent extends StatelessWidget {
child: AttachmentList( child: AttachmentList(
key: Key('m${item.uuid}attachments'), key: Key('m${item.uuid}attachments'),
parentId: item.uuid, parentId: item.uuid,
attachmentsId: attachments, attachmentIds: attachments,
isColumn: true, isColumn: true,
), ),
); );
@ -220,7 +220,7 @@ class ChatEvent extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
AccountAvatar( AttachedCircleAvatar(
content: item.sender.account.avatar, content: item.sender.account.avatar,
radius: 9, radius: 9,
), ),
@ -250,7 +250,8 @@ class ChatEvent extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( GestureDetector(
child: AccountAvatar(content: item.sender.account.avatar), child:
AttachedCircleAvatar(content: item.sender.account.avatar),
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -7,6 +6,7 @@ import 'package:solian/models/event.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/chat/chat_event_deletion.dart'; import 'package:solian/widgets/chat/chat_event_deletion.dart';
import 'package:solian/widgets/loading_indicator.dart';
class ChatEventAction extends StatefulWidget { class ChatEventAction extends StatefulWidget {
final Channel channel; final Channel channel;
@ -73,7 +73,7 @@ class _ChatEventActionState extends State<ChatEventAction> {
), ),
], ],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: ListView( child: ListView(
children: [ children: [

View File

@ -1,4 +1,6 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -9,6 +11,7 @@ import 'package:solian/widgets/chat/chat_event_action.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class ChatEventList extends StatelessWidget { class ChatEventList extends StatelessWidget {
final bool noAnimated;
final String scope; final String scope;
final Channel channel; final Channel channel;
final ChatEventController chatController; final ChatEventController chatController;
@ -23,6 +26,7 @@ class ChatEventList extends StatelessWidget {
required this.chatController, required this.chatController,
required this.onEdit, required this.onEdit,
required this.onReply, required this.onReply,
this.noAnimated = false,
}); });
bool _checkMessageMergeable(Event? a, Event? b) { bool _checkMessageMergeable(Event? a, Event? b) {
@ -31,9 +35,28 @@ class ChatEventList extends StatelessWidget {
return a.createdAt.difference(b.createdAt).inMinutes <= 3; return a.createdAt.difference(b.createdAt).inMinutes <= 3;
} }
void _openActions(BuildContext context, Event item) {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ChatEventAction(
channel: channel,
realm: channel.realm,
item: item,
onEdit: () {
onEdit(item);
},
onReply: () {
onReply(item);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CustomScrollView( return CustomScrollView(
cacheExtent: 100,
reverse: true, reverse: true,
slivers: [ slivers: [
Obx(() { Obx(() {
@ -61,33 +84,45 @@ class ChatEventList extends StatelessWidget {
final item = chatController.currentEvents[index].data; final item = chatController.currentEvents[index].data;
return GestureDetector( return TapRegion(
behavior: HitTestBehavior.opaque, child: GestureDetector(
child: ChatEvent( behavior: HitTestBehavior.opaque,
key: Key('m${item!.uuid}'), child: Builder(builder: (context) {
item: item, final widget = ChatEvent(
isMerged: isMerged, key: Key('m${item!.uuid}'),
chatController: chatController,
).paddingOnly(
top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0,
),
onLongPress: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ChatEventAction(
channel: channel,
realm: channel.realm,
item: item, item: item,
onEdit: () { isMerged: isMerged,
onEdit(item); chatController: chatController,
}, ).paddingOnly(
onReply: () { top: !isMerged ? 8 : 0,
onReply(item); bottom: !hasMerged ? 8 : 0,
}, );
),
); if (noAnimated) {
return widget;
} else {
return widget
.animate(
key: Key('animated-m${item.uuid}'),
)
.slideY(
curve: Curves.fastLinearToSlowEaseIn,
duration: 250.ms,
begin: 0.5,
end: 0,
);
}
}),
onLongPress: () {
_openActions(context, item!);
},
),
onTapInside: (event) {
if (event.buttons == kSecondaryMouseButton) {
_openActions(context, item!);
} else if (event.buttons == kMiddleMouseButton) {
onReply(item!);
}
}, },
); );
}, },

View File

@ -443,7 +443,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
.map( .map(
(x) => ChatMessageSuggestion( (x) => ChatMessageSuggestion(
type: 'users', type: 'users',
leading: AccountAvatar(content: x.avatar), leading: AttachedCircleAvatar(content: x.avatar),
display: x.nick, display: x.nick,
content: '@${x.name}', content: '@${x.name}',
), ),

View File

@ -2,15 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/link.dart';
import 'package:solian/providers/link_expander.dart'; import 'package:solian/providers/link_expander.dart';
import 'package:solian/widgets/auto_cache_image.dart'; import 'package:solian/widgets/auto_cache_image.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class LinkExpansion extends StatelessWidget { class LinkExpansion extends StatefulWidget {
final String content; final String content;
const LinkExpansion({super.key, required this.content}); const LinkExpansion({super.key, required this.content});
@override
State<LinkExpansion> createState() => _LinkExpansionState();
}
class _LinkExpansionState extends State<LinkExpansion> {
Widget _buildImage(String url, {double? width, double? height}) { Widget _buildImage(String url, {double? width, double? height}) {
if (url.endsWith('svg')) { if (url.endsWith('svg')) {
return SvgPicture.network(url, width: width, height: height); return SvgPicture.network(url, width: width, height: height);
@ -22,61 +28,74 @@ class LinkExpansion extends StatelessWidget {
); );
} }
@override List<LinkMeta>? _meta;
Widget build(BuildContext context) {
Future<void> _doExpand() async {
final linkRegex = RegExp( final linkRegex = RegExp(
r'(?<!\()(?:(?:https?):\/\/|www\.)(?:[-_a-z0-9]+\.)*(?:[-a-z0-9]+\.[-a-z0-9]+)[^\s<]*[^\s<?!.,:*_~]', r'(?<!\()(?:(?:https?):\/\/|www\.)(?:[-_a-z0-9]+\.)*(?:[-a-z0-9]+\.[-a-z0-9]+)[^\s<]*[^\s<?!.,:*_~]',
); );
final matches = linkRegex.allMatches(content); final matches = linkRegex.allMatches(widget.content);
if (matches.isEmpty) { if (matches.isEmpty) return;
return const SizedBox.shrink();
}
final LinkExpandProvider expandController = Get.find(); final LinkExpandProvider expandController = Get.find();
if (matches.isEmpty) return;
List<LinkMeta> out = List.empty(growable: true);
for (final x in matches) {
final result = await expandController.expandLink(x.group(0)!);
if (result != null) out.add(result);
}
setState(() => _meta = out);
}
@override
void initState() {
super.initState();
_doExpand();
}
@override
Widget build(BuildContext context) {
if (_meta?.isEmpty ?? true) return const SizedBox.shrink();
return Wrap( return Wrap(
children: matches.map((x) { children: _meta!.map((x) {
return Container( return Container(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: matches.length == 1 ? 480 : 340, maxWidth: _meta!.length == 1 ? 480 : 340,
), ),
child: FutureBuilder( child: Builder(
future: expandController.expandLink(x.group(0)!), builder: (context) {
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
final isRichDescription = [ final isRichDescription = [
'solsynth.dev', 'solsynth.dev',
].contains(Uri.parse(snapshot.data!.url).host); ].contains(Uri.parse(x.url).host);
return GestureDetector( return GestureDetector(
child: Card( child: Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if ([ if ([(x.icon?.isNotEmpty ?? false), x.siteName != null]
(snapshot.data!.icon?.isNotEmpty ?? false), .any((x) => x))
snapshot.data!.siteName != null
].any((x) => x))
Row( Row(
children: [ children: [
if (snapshot.data!.icon?.isNotEmpty ?? false) if (x.icon?.isNotEmpty ?? false)
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.all( borderRadius: const BorderRadius.all(
Radius.circular(8), Radius.circular(8),
), ),
child: _buildImage( child: _buildImage(
snapshot.data!.icon!, x.icon!,
width: 32, width: 32,
height: 32, height: 32,
), ),
).paddingOnly(right: 8), ).paddingOnly(right: 8),
if (snapshot.data!.siteName != null) if (x.siteName != null)
Expanded( Expanded(
child: Text( child: Text(
snapshot.data!.siteName!, x.siteName!,
style: Theme.of(context).textTheme.labelLarge, style: Theme.of(context).textTheme.labelLarge,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -84,32 +103,27 @@ class LinkExpansion extends StatelessWidget {
), ),
], ],
).paddingOnly( ).paddingOnly(
bottom: (snapshot.data!.icon?.isNotEmpty ?? false) bottom: (x.icon?.isNotEmpty ?? false) ? 8 : 4,
? 8
: 4,
), ),
if (snapshot.data!.image != null && if (x.image != null &&
(snapshot.data!.image?.startsWith('http') ?? false)) (x.image?.startsWith('http') ?? false))
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.all( borderRadius: const BorderRadius.all(
Radius.circular(8), Radius.circular(8),
), ),
child: _buildImage( child: _buildImage(x.image!),
snapshot.data!.image!,
),
).paddingOnly(bottom: 8), ).paddingOnly(bottom: 8),
Text( Text(
snapshot.data!.title ?? 'No Title', x.title ?? 'No Title',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
if (snapshot.data!.description != null && if (x.description != null && isRichDescription)
isRichDescription) MarkdownBody(data: x.description!)
MarkdownBody(data: snapshot.data!.description!) else if (x.description != null)
else if (snapshot.data!.description != null)
Text( Text(
snapshot.data!.description!, x.description!,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -117,7 +131,7 @@ class LinkExpansion extends StatelessWidget {
).paddingAll(12), ).paddingAll(12),
), ),
onTap: () { onTap: () {
launchUrlString(x.group(0)!); launchUrlString(x.url);
}, },
); );
}, },

View File

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:gap/gap.dart';
class LoadingIndicator extends StatefulWidget {
final bool isActive;
final Color? backgroundColor;
const LoadingIndicator({
super.key,
this.isActive = true,
this.backgroundColor,
});
@override
State<LoadingIndicator> createState() => _LoadingIndicatorState();
}
class _LoadingIndicatorState extends State<LoadingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
if (widget.isActive) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
void didUpdateWidget(covariant LoadingIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive != oldWidget.isActive) {
if (widget.isActive) {
_controller.forward();
} else {
_controller.reverse();
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: _animation,
axisAlignment: -1, // Align animation from the top
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
color: widget.backgroundColor ??
Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
const Gap(8),
Text('loading'.tr),
],
),
),
);
}
}

View File

@ -1,19 +1,25 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_selectionarea/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:markdown/markdown.dart' as markdown; import 'package:markdown/markdown.dart' as markdown;
import 'package:markdown/markdown.dart'; import 'package:path/path.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/providers/stickers.dart'; import 'package:solian/providers/stickers.dart';
import 'package:solian/widgets/attachments/attachment_item.dart';
import 'package:solian/widgets/attachments/attachment_list.dart'; import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/auto_cache_image.dart'; import 'package:solian/widgets/auto_cache_image.dart';
import 'package:syntax_highlight/syntax_highlight.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'account/account_profile_popup.dart'; import 'account/account_profile_popup.dart';
class MarkdownTextContent extends StatelessWidget { class MarkdownTextContent extends StatefulWidget {
final String content; final String content;
final String parentId; final String parentId;
final List<Attachment>? attachments;
final bool isSelectable; final bool isSelectable;
final bool isLargeText; final bool isLargeText;
final bool isAutoWarp; final bool isAutoWarp;
@ -22,190 +28,228 @@ class MarkdownTextContent extends StatelessWidget {
super.key, super.key,
required this.content, required this.content,
required this.parentId, required this.parentId,
this.attachments,
this.isSelectable = false, this.isSelectable = false,
this.isLargeText = false, this.isLargeText = false,
this.isAutoWarp = false, this.isAutoWarp = false,
}); });
Widget _buildContent(BuildContext context) { @override
State<MarkdownTextContent> createState() => _MarkdownTextContentState();
}
class _MarkdownTextContentState extends State<MarkdownTextContent> {
final List<int> _stickerSizes = [];
@override
initState() {
super.initState();
final stickerRegex = RegExp(r':([-\w]+):'); final stickerRegex = RegExp(r':([-\w]+):');
// Split the content into paragraphs // Split the content into paragraphs
final paragraphs = content.split(RegExp(r'\n\s*\n')); final paragraphs = widget.content.split(RegExp(r'\n\s*\n'));
// Iterate over each paragraph to process stickers individually // Iterate over each paragraph to process stickers individually
List<Widget> contentWidgets = [];
for (var idx = 0; idx < paragraphs.length; idx++) { for (var idx = 0; idx < paragraphs.length; idx++) {
// Getting paragraph // Getting paragraph
var paragraph = paragraphs[idx]; var paragraph = paragraphs[idx];
// Auto adding new-lines
if (isAutoWarp) {
paragraph = paragraph.replaceAll('\n', '\\\n');
}
// Matching stickers // Matching stickers
final stickerMatch = stickerRegex.allMatches(paragraph); final stickerMatch = stickerRegex.allMatches(paragraph);
final isOnlySticker = if (stickerMatch.length > 3) {
paragraph.replaceAll(stickerRegex, '').trim().isEmpty; _stickerSizes.addAll(List.filled(stickerMatch.length, 16));
} else if (stickerMatch.length > 1) {
_stickerSizes.addAll(List.filled(stickerMatch.length, 32));
} else {
_stickerSizes.addAll(List.filled(stickerMatch.length, 128));
}
}
}
contentWidgets.add( Widget _buildContent(BuildContext context) {
Markdown( var stickerIdx = 0;
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), return Markdown(
data: paragraph, shrinkWrap: true,
padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(),
styleSheet: MarkdownStyleSheet.fromTheme( data: widget.content,
Theme.of(context), padding: EdgeInsets.zero,
).copyWith( styleSheet: MarkdownStyleSheet.fromTheme(
textScaleFactor: isLargeText ? 1.1 : 1, Theme.of(context),
blockquote: TextStyle( ).copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, textScaler: TextScaler.linear(widget.isLargeText ? 1.1 : 1),
), blockquote: TextStyle(
blockquoteDecoration: BoxDecoration( color: Theme.of(context).colorScheme.onSurfaceVariant,
color: Theme.of(context).colorScheme.surfaceContainerHigh, ),
borderRadius: const BorderRadius.all(Radius.circular(4)), blockquoteDecoration: BoxDecoration(
), color: Theme.of(context).colorScheme.surfaceContainerHigh,
horizontalRuleDecoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)),
border: Border( ),
top: BorderSide( horizontalRuleDecoration: BoxDecoration(
width: 1.0, border: Border(
color: Theme.of(context).dividerColor, top: BorderSide(
), width: 1.0,
color: Theme.of(context).dividerColor,
), ),
), ),
), ),
extensionSet: markdown.ExtensionSet( codeblockDecoration: BoxDecoration(
markdown.ExtensionSet.gitHubFlavored.blockSyntaxes, border: Border.all(
<markdown.InlineSyntax>[ color: Theme.of(context).dividerColor,
_UserNameCardInlineSyntax(), width: 0.3,
_CustomEmoteInlineSyntax(), ),
markdown.EmojiSyntax(), borderRadius: const BorderRadius.all(Radius.circular(4)),
markdown.AutolinkSyntax(), color: Theme.of(context).colorScheme.surface.withOpacity(0.5),
markdown.AutolinkExtensionSyntax(), )),
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes builders: {
], 'code': _MarkdownTextCodeElement(),
), },
onTapLink: (text, href, title) async { softLineBreak: true,
if (href == null) return; extensionSet: markdown.ExtensionSet(
if (href.startsWith('solink://')) { <markdown.BlockSyntax>[
final segments = href.replaceFirst('solink://', '').split('/'); markdown.CodeBlockSyntax(),
switch (segments[0]) { ...markdown.ExtensionSet.commonMark.blockSyntaxes,
case 'users': ...markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
showModalBottomSheet( ],
useRootNavigator: true, <markdown.InlineSyntax>[
isScrollControlled: true, if (widget.isAutoWarp) markdown.LineBreakSyntax(),
backgroundColor: Theme.of(context).colorScheme.surface, _UserNameCardInlineSyntax(),
context: context, _CustomEmoteInlineSyntax(),
builder: (context) => AccountProfilePopup( markdown.AutolinkSyntax(),
name: segments[1], markdown.AutolinkExtensionSyntax(),
), markdown.CodeSyntax(),
); ...markdown.ExtensionSet.commonMark.inlineSyntaxes,
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
],
),
onTapLink: (text, href, title) async {
if (href == null) return;
if (href.startsWith('solink://')) {
final segments = href.replaceFirst('solink://', '').split('/');
switch (segments[0]) {
case 'users':
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
name: segments[1],
),
);
}
return;
}
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
imageBuilder: (uri, title, alt) {
var url = uri.toString();
double? width, height;
BoxFit? fit;
if (url.startsWith('solink://')) {
final segments = url.replaceFirst('solink://', '').split('/');
switch (segments[0]) {
case 'stickers':
double radius = 4;
final StickerProvider sticker = Get.find();
// Adjust sticker size based on the sticker count in this paragraph
width =
_stickerSizes.elementAtOrNull(stickerIdx)?.toDouble() ?? 16;
height =
_stickerSizes.elementAtOrNull(stickerIdx)?.toDouble() ?? 16;
if (width > 16) {
radius = 8;
} }
return; stickerIdx++;
} fit = BoxFit.contain;
return ClipRRect(
await launchUrlString( borderRadius: BorderRadius.all(Radius.circular(radius)),
href, child: Container(
mode: LaunchMode.externalApplication, width: width,
); height: height,
}, color: Theme.of(context).colorScheme.surfaceContainer,
imageBuilder: (uri, title, alt) { child: FutureBuilder(
var url = uri.toString(); future: sticker.getStickerByAlias(segments[1]),
double? width, height; builder: (context, snapshot) {
BoxFit? fit; if (!snapshot.hasData) {
if (url.startsWith('solink://')) { return const Center(child: CircularProgressIndicator());
final segments = url.replaceFirst('solink://', '').split('/'); }
switch (segments[0]) { return AutoCacheImage(
case 'stickers': snapshot.data!.imageUrl,
double radius = 8; width: width,
final StickerProvider sticker = Get.find(); height: height,
fit: fit,
// Adjust sticker size based on the sticker count in this paragraph noErrorWidget: true,
if (stickerMatch.length <= 1 && isOnlySticker) { );
width = 128; },
height = 128; ),
} else if (stickerMatch.length <= 3 && isOnlySticker) { ),
width = 32; ).paddingSymmetric(vertical: 4);
height = 32; case 'attachments':
} else { final match = widget.attachments
radius = 4; ?.where((x) => x.rid == segments[1])
width = 16; .firstOrNull;
height = 16; const radius = BorderRadius.all(Radius.circular(8));
} if (match != null) {
fit = BoxFit.contain; final isImage =
return ClipRRect( match.mimetype.split('/').firstOrNull == 'image';
borderRadius: BorderRadius.all(Radius.circular(radius)), double ratio = match.metadata?['ratio']?.toDouble() ??
child: Container( (isImage ? 1 : 16 / 9);
width: width, return LimitedBox(
height: height, maxWidth: 480,
color: Theme.of(context).colorScheme.surfaceContainer, maxHeight: 640,
child: FutureBuilder( child: AspectRatio(
future: sticker.getStickerByAlias(segments[1]), aspectRatio: ratio,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator());
}
return AutoCacheImage(
snapshot.data!.imageUrl,
width: width,
height: height,
fit: fit,
noErrorWidget: true,
);
},
),
),
).paddingSymmetric(vertical: 4);
case 'attachments':
const radius = BorderRadius.all(Radius.circular(8));
return LimitedBox(
maxHeight: MediaQuery.of(context).size.width,
child: ClipRRect( child: ClipRRect(
borderRadius: radius, borderRadius: radius,
child: AttachmentSelfContainedEntry( child: AttachmentItem(
isDense: true, parentId: widget.parentId,
parentId: parentId, item: match,
rid: segments[1],
), ),
), ),
).paddingSymmetric(vertical: 4); ),
).paddingSymmetric(vertical: 4);
} else {
return LimitedBox(
maxHeight: MediaQuery.of(context).size.width,
child: ClipRRect(
borderRadius: radius,
child: AttachmentSelfContainedEntry(
isDense: true,
parentId: widget.parentId,
rid: segments[1],
),
),
).paddingSymmetric(vertical: 4);
} }
} }
return AutoCacheImage( }
url, return AutoCacheImage(
width: width, url,
height: height, width: width,
fit: fit, height: height,
); fit: fit,
}, );
), },
);
if (idx < paragraphs.length - 1) {
contentWidgets.add(const Gap(4));
}
}
// Return the list of widgets for the paragraphs
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: contentWidgets,
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (isSelectable) { if (widget.isSelectable) {
return SelectionArea(child: _buildContent(context)); return SelectionArea(child: _buildContent(context));
} }
return _buildContent(context); return _buildContent(context);
} }
} }
class _UserNameCardInlineSyntax extends InlineSyntax { class _UserNameCardInlineSyntax extends markdown.InlineSyntax {
_UserNameCardInlineSyntax() : super(r'@[a-zA-Z0-9_]+'); _UserNameCardInlineSyntax() : super(r'@[a-zA-Z0-9_]+');
@override @override
@ -221,7 +265,7 @@ class _UserNameCardInlineSyntax extends InlineSyntax {
} }
} }
class _CustomEmoteInlineSyntax extends InlineSyntax { class _CustomEmoteInlineSyntax extends markdown.InlineSyntax {
_CustomEmoteInlineSyntax() : super(r':([-\w]+):'); _CustomEmoteInlineSyntax() : super(r':([-\w]+):');
@override @override
@ -241,3 +285,48 @@ class _CustomEmoteInlineSyntax extends InlineSyntax {
return true; return true;
} }
} }
class _MarkdownTextCodeElement extends MarkdownElementBuilder {
@override
Widget? visitElementAfter(
markdown.Element element,
TextStyle? preferredStyle,
) {
var language = '';
if (element.attributes['class'] != null) {
String lg = element.attributes['class'] as String;
language = lg.substring(9).trim();
}
return SizedBox(
child: FutureBuilder(
future: (() async {
final docPath = '../../../';
final highlightingPath =
join(docPath, 'assets/highlighting', language);
await Highlighter.initialize([highlightingPath]);
return Highlighter(
language: highlightingPath,
theme: PlatformDispatcher.instance.platformBrightness ==
Brightness.light
? await HighlighterTheme.loadLightTheme()
: await HighlighterTheme.loadDarkTheme(),
);
})(),
builder: (context, snapshot) {
if (snapshot.hasData) {
final highlighter = snapshot.data!;
return Text.rich(
highlighter.highlight(element.textContent.trim()),
style: GoogleFonts.robotoMono(),
);
}
return Text(
element.textContent.trim(),
style: GoogleFonts.robotoMono(),
);
},
),
).paddingAll(8);
}
}

View File

@ -69,7 +69,7 @@ class _AppAccountWidgetState extends State<AppAccountWidget> {
bottom: 0, bottom: 0,
end: -2, end: -2,
), ),
child: AccountAvatar( child: AttachedCircleAvatar(
radius: 14, radius: 14,
content: auth.userProfile.value!['avatar'], content: auth.userProfile.value!['avatar'],
), ),

View File

@ -27,39 +27,43 @@ class _AppNavigationRailState extends State<AppNavigationRail> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return NavigationRail( return Material(
selectedIndex: _currentIndex, color: Theme.of(context).colorScheme.surface,
labelType: NavigationRailLabelType.selected, child: NavigationRail(
groupAlignment: -1, selectedIndex: _currentIndex,
destinations: AppNavigation.destinations labelType: NavigationRailLabelType.selected,
.sublist(0, AppNavigation.destinations.length - 1) groupAlignment: -1,
.map( destinations: AppNavigation.destinations
(x) => NavigationRailDestination( .sublist(0, AppNavigation.destinations.length - 1)
icon: x.icon, .map(
label: Text(x.label), (x) => NavigationRailDestination(
icon: x.icon,
label: Text(x.label),
),
)
.toList(),
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: IconButton(
icon: AppNavigation.destinations.last.icon,
tooltip: AppNavigation.destinations.last.label,
onPressed: () {
setState(() => _currentIndex = null);
AppRouter.instance
.goNamed(AppNavigation.destinations.last.page);
},
), ),
)
.toList(),
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: IconButton(
icon: AppNavigation.destinations.last.icon,
tooltip: AppNavigation.destinations.last.label,
onPressed: () {
setState(() => _currentIndex = null);
AppRouter.instance.goNamed(AppNavigation.destinations.last.page);
},
), ),
), ),
onDestinationSelected: (idx) {
setState(() => _currentIndex = idx);
AppRouter.instance.goNamed(AppNavigation.destinations[idx].page);
},
).paddingOnly(
top: max(16, MediaQuery.of(context).padding.top),
bottom: max(16, MediaQuery.of(context).padding.bottom),
), ),
onDestinationSelected: (idx) {
setState(() => _currentIndex = idx);
AppRouter.instance.goNamed(AppNavigation.destinations[idx].page);
},
).paddingOnly(
top: max(16, MediaQuery.of(context).padding.top),
bottom: max(16, MediaQuery.of(context).padding.bottom),
); );
} }
} }

View File

@ -36,7 +36,7 @@ class RealmSwitcher extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (item != null) if (item != null)
AccountAvatar( AttachedCircleAvatar(
content: item.avatar, content: item.avatar,
radius: 14, radius: 14,
fallbackWidget: const Icon( fallbackWidget: const Icon(

View File

@ -1,17 +1,21 @@
import 'dart:math'; import 'dart:math';
import 'package:file_saver/file_saver.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/posts/post_editor.dart'; import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/posts/post_share.dart';
import 'package:solian/widgets/reports/abuse_report.dart'; import 'package:solian/widgets/reports/abuse_report.dart';
class PostAction extends StatefulWidget { class PostAction extends StatefulWidget {
@ -25,20 +29,14 @@ class PostAction extends StatefulWidget {
} }
class _PostActionState extends State<PostAction> { class _PostActionState extends State<PostAction> {
bool _isBusy = true; bool _isBusy = false;
bool _canModifyContent = false; bool _canModifyContent = false;
void _checkAbleToModifyContent() async { void _checkAbleToModifyContent() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
setState(() => _isBusy = true); _canModifyContent = auth.userProfile.value!['id'] == widget.item.author.id;
setState(() {
_canModifyContent =
auth.userProfile.value!['id'] == widget.item.author.id;
_isBusy = false;
});
} }
Future<void> _doShare({bool noUri = false}) async { Future<void> _doShare({bool noUri = false}) async {
@ -69,7 +67,8 @@ class _PostActionState extends State<PostAction> {
'link': 'https://solsynth.dev/posts/$id', 'link': 'https://solsynth.dev/posts/$id',
}), }),
subject: 'postShareSubject'.trParams({ subject: 'postShareSubject'.trParams({
'username': widget.item.author.nick, 'username': '@${widget.item.author.name}',
'title': widget.item.body['title'] ?? '#${widget.item.id}',
}), }),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
); );
@ -84,6 +83,78 @@ class _PostActionState extends State<PostAction> {
} }
} }
Future<void> _shareImage() async {
final List<String> attachments = widget.item.body['attachments'] is List
? List.from(widget.item.body['attachments']?.whereType<String>())
: List.empty();
final hasMultipleAttachment = attachments.length > 1;
setState(() => _isBusy = true);
final double width = hasMultipleAttachment ? 640 : 480;
final screenshot = ScreenshotController();
final image = await screenshot.captureFromLongWidget(
MediaQuery(
data: MediaQuery.of(context).copyWith(
size: Size(width, double.infinity),
),
child: PostShareImage(item: widget.item),
),
context: context,
pixelRatio: 2,
constraints: BoxConstraints(
minWidth: 480,
maxWidth: width,
minHeight: 640,
maxHeight: double.infinity,
),
);
final filename = 'share_post#${widget.item.id}';
if (PlatformInfo.isAndroid || PlatformInfo.isIOS) {
final box = context.findRenderObject() as RenderBox?;
final file = XFile.fromData(
image,
mimeType: 'image/png',
name: filename,
);
await Share.shareXFiles(
[file],
subject: 'postShareSubject'.trParams({
'username': '@${widget.item.author.name}',
'title': widget.item.body['title'] ?? '#${widget.item.id}',
}),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
);
} else {
final filepath = await FileSaver.instance.saveFile(
name: filename,
ext: 'png',
mimeType: MimeType.png,
bytes: image,
);
context.showSnackbar('fileSavedAt'.trParams({'path': filepath}));
}
setState(() => _isBusy = false);
}
Future<Post> _getFullPost() async {
final PostProvider posts = Get.find();
try {
final resp = await posts.getPost(widget.item.id.toString());
return Post.fromJson(resp.body);
} catch (e) {
context.showErrorDialog(e).then((_) => Navigator.pop(context));
}
return widget.item;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -127,7 +198,13 @@ class _PostActionState extends State<PostAction> {
), ),
], ],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(
isActive: _isBusy,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHigh
.withOpacity(0.5),
),
Expanded( Expanded(
child: ListView( child: ListView(
children: [ children: [
@ -135,16 +212,30 @@ class _PostActionState extends State<PostAction> {
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.share), leading: const Icon(Icons.share),
title: Text('share'.tr), title: Text('share'.tr),
trailing: PlatformInfo.isIOS || PlatformInfo.isAndroid trailing: Row(
? IconButton( mainAxisSize: MainAxisSize.min,
children: [
if (PlatformInfo.isIOS || PlatformInfo.isAndroid)
IconButton(
icon: const Icon(Icons.link_off), icon: const Icon(Icons.link_off),
tooltip: 'shareNoUri'.tr, tooltip: 'shareNoUri'.tr,
onPressed: () async { onPressed: () async {
await _doShare(noUri: true); await _doShare(noUri: true);
Navigator.pop(context); Navigator.pop(context);
}, },
) ),
: null, IconButton(
icon: const Icon(Icons.image),
tooltip: 'shareImage'.tr,
onPressed: _isBusy
? null
: () async {
await _shareImage();
Navigator.pop(context);
},
),
],
),
onTap: () async { onTap: () async {
await _doShare(); await _doShare();
Navigator.pop(context); Navigator.pop(context);
@ -221,15 +312,23 @@ class _PostActionState extends State<PostAction> {
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.edit), leading: const Icon(Icons.edit),
title: Text('edit'.tr), title: Text('edit'.tr),
onTap: () async { onTap: _isBusy
Navigator.pop( ? null
context, : () async {
AppRouter.instance.pushNamed( setState(() => _isBusy = true);
'postEditor', var item = widget.item;
extra: PostPublishArguments(edit: widget.item), if (item.body?['content_truncated'] == true) {
), item = await _getFullPost();
); }
}, Navigator.pop(
context,
AppRouter.instance.pushNamed(
'postEditor',
extra: PostPublishArguments(edit: item),
),
);
if (mounted) setState(() => _isBusy = false);
},
), ),
if (_canModifyContent) if (_canModifyContent)
ListTile( ListTile(

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
class PostCreatePopup extends StatelessWidget {
final bool hideDraftBox;
const PostCreatePopup({
super.key,
this.hideDraftBox = false,
});
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) {
return const SizedBox.shrink();
}
final List<dynamic> actionList = [
(
icon: const Icon(Icons.post_add),
label: 'postEditorModeStory'.tr,
onTap: () {
Navigator.pop(
context,
AppRouter.instance.pushNamed(
'postEditor',
queryParameters: {
'mode': 0.toString(),
},
),
);
},
),
(
icon: const Icon(Icons.description),
label: 'postEditorModeArticle'.tr,
onTap: () {
Navigator.pop(
context,
AppRouter.instance.pushNamed(
'postEditor',
queryParameters: {
'mode': 1.toString(),
},
),
);
},
),
(
icon: const Icon(Icons.drafts),
label: 'draftBoxOpen'.tr,
onTap: () {
Navigator.pop(
context,
AppRouter.instance.pushNamed('draftBox'),
);
},
),
];
return SizedBox(
height: MediaQuery.of(context).size.height * 0.38,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'postNew'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
Expanded(
child: GridView.count(
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
children: actionList
.map((x) => Card(
color: Theme.of(context).colorScheme.surfaceContainer,
child: InkWell(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
onTap: x.onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
x.icon,
const Gap(8),
Expanded(
child: Text(
x.label,
overflow: TextOverflow.fade,
),
),
],
).paddingAll(18),
),
))
.toList(),
).paddingSymmetric(horizontal: 20),
),
],
),
);
}
}

View File

@ -1,6 +1,5 @@
import 'package:animations/animations.dart'; import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@ -8,11 +7,11 @@ import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/content/posts.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/posts/post_detail.dart'; import 'package:solian/screens/posts/post_detail.dart';
import 'package:solian/shells/title_shell.dart'; import 'package:solian/shells/title_shell.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/attachments/attachment_list.dart'; import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/link_expansion.dart'; import 'package:solian/widgets/link_expansion.dart';
import 'package:solian/widgets/markdown_text_content.dart'; import 'package:solian/widgets/markdown_text_content.dart';
@ -31,11 +30,15 @@ class PostItem extends StatefulWidget {
final bool isShowEmbed; final bool isShowEmbed;
final bool isOverrideEmbedClickable; final bool isOverrideEmbedClickable;
final bool isFullDate; final bool isFullDate;
final bool isFullContent;
final bool isContentSelectable; final bool isContentSelectable;
final bool isNonScrollAttachment;
final bool showFeaturedReply; final bool showFeaturedReply;
final String? attachmentParent; final String? attachmentParent;
final Color? backgroundColor;
final EdgeInsets? padding;
final Function? onComment;
final Function? onTapMore;
const PostItem({ const PostItem({
super.key, super.key,
@ -47,11 +50,13 @@ class PostItem extends StatefulWidget {
this.isShowEmbed = true, this.isShowEmbed = true,
this.isOverrideEmbedClickable = false, this.isOverrideEmbedClickable = false,
this.isFullDate = false, this.isFullDate = false,
this.isFullContent = false,
this.isContentSelectable = false, this.isContentSelectable = false,
this.isNonScrollAttachment = false,
this.showFeaturedReply = false, this.showFeaturedReply = false,
this.attachmentParent, this.attachmentParent,
this.backgroundColor, this.padding,
this.onComment,
this.onTapMore,
}); });
@override @override
@ -64,14 +69,20 @@ class _PostItemState extends State<PostItem> {
Color get _unFocusColor => Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75); Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
static final visibilityIcons = [
Icons.public,
Icons.group,
Icons.visibility,
Icons.visibility_off,
Icons.lock,
];
@override @override
void initState() { void initState() {
item = widget.item; item = widget.item;
super.initState(); super.initState();
} }
double _contentHeight = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final List<String> attachments = item.body['attachments'] is List final List<String> attachments = item.body['attachments'] is List
@ -89,218 +100,126 @@ class _PostItemState extends State<PostItem> {
).paddingOnly(bottom: 8), ).paddingOnly(bottom: 8),
_PostHeaderWidget( _PostHeaderWidget(
isCompact: widget.isCompact, isCompact: widget.isCompact,
isFullDate: widget.isFullDate,
onTapMore: widget.onTapMore,
item: item, item: item,
).paddingSymmetric(horizontal: 12), ).paddingSymmetric(horizontal: 12),
_PostHeaderDividerWidget(item: item).paddingSymmetric(horizontal: 12), _PostHeaderDividerWidget(item: item).paddingSymmetric(horizontal: 12),
Stack( SizedContainer(
children: [ maxWidth: 640,
SizedContainer( child: MarkdownTextContent(
maxWidth: 640, parentId: 'p${item.id}',
maxHeight: widget.isFullContent ? double.infinity : 80, content: item.body['content'],
child: _MeasureSize( attachments: item.preload?.attachments,
onChange: (size) { isAutoWarp: item.type == 'story',
setState(() => _contentHeight = size.height); isSelectable: widget.isContentSelectable,
}, ),
child: SingleChildScrollView( ).paddingOnly(
physics: const NeverScrollableScrollPhysics(), left: 12,
child: MarkdownTextContent( right: 12,
parentId: 'p${item.id}', bottom: hasAttachment ? 4 : 0,
content: item.body['content'],
isAutoWarp: item.type == 'story',
isSelectable: widget.isContentSelectable,
),
).paddingOnly(
left: 16,
right: 12,
top: 2,
bottom: hasAttachment ? 4 : 0,
),
),
),
if (_contentHeight >= 80 && !widget.isFullContent)
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(context).colorScheme.surfaceContainerLow,
Theme.of(context)
.colorScheme
.surface
.withOpacity(0),
],
),
),
),
),
),
],
), ),
if (widget.item.body?['content_truncated'] == true)
Opacity(
opacity: 0.8,
child: InkWell(child: Text('readMore'.tr)),
).paddingOnly(
left: 12,
top: 4,
),
LinkExpansion(content: item.body['content']).paddingOnly( LinkExpansion(content: item.body['content']).paddingOnly(
left: 8, left: 8,
right: 8, right: 8,
top: 4,
), ),
_PostFooterWidget(item: item).paddingOnly(left: 16),
if (attachments.isNotEmpty) if (attachments.isNotEmpty)
Row( _PostAttachmentWidget(
children: [ item: item,
Icon( padding: widget.padding,
Icons.file_copy, isCompact: true,
size: 15, isNonScrollAttachment: widget.isNonScrollAttachment,
color: _unFocusColor, ).paddingOnly(top: 4),
).paddingOnly(right: 5),
Text(
'attachmentHint'.trParams(
{'count': attachments.length.toString()},
),
style: TextStyle(color: _unFocusColor),
)
],
).paddingOnly(left: 16, top: 4),
], ],
); );
} }
return OpenContainer( return GestureDetector(
tappable: widget.isClickable, child: Column(
closedBuilder: (_, openContainer) => Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_PostThumbnail( _PostThumbnail(
rid: item.body['thumbnail'], rid: item.body['thumbnail'],
parentId: widget.item.id.toString(), parentId: widget.item.id.toString(),
).paddingOnly(bottom: 4), ).paddingOnly(bottom: 4),
Row( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( _PostHeaderWidget(
child: AccountAvatar(content: item.author.avatar), isCompact: widget.isCompact,
onTap: () { isFullDate: widget.isFullDate,
showModalBottomSheet( onTapMore: widget.onTapMore,
useRootNavigator: true, item: item,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
name: item.author.name,
),
);
},
), ),
Expanded( _PostHeaderDividerWidget(item: item),
child: Column( SizedContainer(
crossAxisAlignment: CrossAxisAlignment.start, maxWidth: 640,
children: [ child: MarkdownTextContent(
_PostHeaderWidget( parentId: 'p${item.id}-embed',
isCompact: widget.isCompact, content: item.body['content'],
item: item, attachments: item.preload?.attachments,
), isAutoWarp: item.type == 'story',
_PostHeaderDividerWidget(item: item), isSelectable: widget.isContentSelectable,
Stack(
children: [
SizedContainer(
maxWidth: 640,
maxHeight:
widget.isFullContent ? double.infinity : 320,
child: _MeasureSize(
onChange: (size) {
setState(() => _contentHeight = size.height);
},
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: MarkdownTextContent(
parentId: 'p${item.id}-embed',
content: item.body['content'],
isAutoWarp: item.type == 'story',
isSelectable: widget.isContentSelectable,
isLargeText: item.type == 'article' &&
widget.isFullContent,
).paddingOnly(left: 12, right: 8),
),
),
),
if (_contentHeight >= 320 && !widget.isFullContent)
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
height: 320,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
(widget.backgroundColor ??
Theme.of(context)
.colorScheme
.surface),
(widget.backgroundColor ??
Theme.of(context)
.colorScheme
.surface)
.withOpacity(0),
],
),
),
),
),
),
],
),
if (widget.item.replyTo != null && widget.isShowEmbed)
Container(
constraints: const BoxConstraints(maxWidth: 480),
padding: const EdgeInsets.only(top: 4),
child: _PostEmbedWidget(
isClickable: widget.isClickable,
isOverrideEmbedClickable:
widget.isOverrideEmbedClickable,
item: widget.item.replyTo!,
username: widget.item.replyTo!.author.name,
hintText: 'postRepliedNotify',
icon: FontAwesomeIcons.reply,
id: widget.item.replyTo!.id.toString(),
),
),
if (widget.item.repostTo != null && widget.isShowEmbed)
Container(
constraints: const BoxConstraints(maxWidth: 480),
padding: const EdgeInsets.only(top: 4),
child: _PostEmbedWidget(
isClickable: widget.isClickable,
isOverrideEmbedClickable:
widget.isOverrideEmbedClickable,
item: widget.item.repostTo!,
username: widget.item.repostTo!.author.name,
hintText: 'postRepostedNotify',
icon: FontAwesomeIcons.retweet,
id: widget.item.repostTo!.id.toString(),
),
),
_PostFooterWidget(item: item).paddingOnly(left: 12),
LinkExpansion(content: item.body['content'])
.paddingOnly(top: 4),
],
), ),
), ),
if (widget.item.body?['content_truncated'] == true)
Opacity(
opacity: 0.8,
child: InkWell(child: Text('readMore'.tr)),
).paddingOnly(top: 4),
if (widget.item.replyTo != null && widget.isShowEmbed)
Container(
constraints: const BoxConstraints(maxWidth: 480),
padding: const EdgeInsets.only(top: 8),
child: _PostEmbedWidget(
isClickable: widget.isClickable,
isOverrideEmbedClickable: widget.isOverrideEmbedClickable,
item: widget.item.replyTo!,
username: widget.item.replyTo!.author.name,
hintText: 'postRepliedNotify',
icon: FontAwesomeIcons.reply,
id: widget.item.replyTo!.id.toString(),
),
),
if (widget.item.repostTo != null && widget.isShowEmbed)
Container(
constraints: const BoxConstraints(maxWidth: 480),
padding: const EdgeInsets.only(top: 8),
child: _PostEmbedWidget(
isClickable: widget.isClickable,
isOverrideEmbedClickable: widget.isOverrideEmbedClickable,
item: widget.item.repostTo!,
username: widget.item.repostTo!.author.name,
hintText: 'postRepostedNotify',
icon: FontAwesomeIcons.retweet,
id: widget.item.repostTo!.id.toString(),
),
),
_PostFooterWidget(item: item),
LinkExpansion(content: item.body['content']),
], ],
).paddingOnly( ).paddingSymmetric(
top: 10, horizontal: (widget.padding?.horizontal ?? 0) + 16,
bottom:
(attachments.length == 1 && !AppTheme.isLargeScreen(context))
? 10
: 0,
right: 16,
left: 16,
), ),
_PostAttachmentWidget(item: item), if (hasAttachment) const Gap(8),
if (widget.showFeaturedReply) _PostFeaturedReplyWidget(item: item), _PostAttachmentWidget(
item: item,
padding: widget.padding,
isCompact: item.type == 'article',
isNonScrollAttachment: widget.isNonScrollAttachment,
),
if (widget.showFeaturedReply)
_PostFeaturedReplyWidget(item: item).paddingSymmetric(
horizontal: (widget.padding?.horizontal ?? 0) + 12,
),
if (widget.isShowReply || widget.isReactable) if (widget.isShowReply || widget.isReactable)
PostQuickAction( PostQuickAction(
isShowReply: widget.isShowReply, isShowReply: widget.isShowReply,
@ -312,33 +231,30 @@ class _PostItemState extends State<PostItem> {
(item.metric!.reactionList[symbol] ?? 0) + changes; (item.metric!.reactionList[symbol] ?? 0) + changes;
}); });
}, },
onComment: () {
if (widget.onComment != null) {
widget.onComment!();
}
},
).paddingOnly( ).paddingOnly(
top: (attachments.length == 1 && !AppTheme.isLargeScreen(context)) top: 8,
? 10 left: (widget.padding?.left ?? 0) + 14,
: 6, right: (widget.padding?.right ?? 0) + 14,
left:
(attachments.length == 1 && !AppTheme.isLargeScreen(context))
? 24
: 60,
right: 16,
bottom: 10,
) )
else
const Gap(10),
], ],
).paddingOnly(
top: widget.padding?.top ?? 0,
bottom: widget.padding?.bottom ?? 0,
), ),
openBuilder: (_, __) => TitleShell( onTap: () {
title: 'postDetail'.tr, if (widget.isClickable) {
child: PostDetailScreen( AppRouter.instance.pushNamed(
id: item.id.toString(), 'postDetail',
post: item, pathParameters: {'id': item.id.toString()},
), extra: item,
), );
closedElevation: 0, }
openElevation: 0, },
closedColor:
widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
openColor: Theme.of(context).colorScheme.surface,
); );
} }
} }
@ -350,7 +266,6 @@ class _PostFeaturedReplyWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isLargeScreen = AppTheme.isLargeScreen(context);
final unFocusColor = final unFocusColor =
Theme.of(context).colorScheme.onSurface.withOpacity(0.75); Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
@ -358,19 +273,17 @@ class _PostFeaturedReplyWidget extends StatelessWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final List<String> attachments = item.body['attachments'] is List
? List.from(item.body['attachments']?.whereType<String>())
: List.empty();
return FutureBuilder( return FutureBuilder(
future: future: Get.find<PostProvider>().listPostFeaturedReply(
Get.find<PostProvider>().listPostFeaturedReply(item.id.toString()), item.id.toString(),
),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data!.isEmpty) { if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return Container( return Container(
padding: EdgeInsets.only(top: 8),
constraints: const BoxConstraints(maxWidth: 480), constraints: const BoxConstraints(maxWidth: 480),
child: Card( child: Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
@ -384,7 +297,7 @@ class _PostFeaturedReplyWidget extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AccountAvatar( AttachedCircleAvatar(
content: reply.author.avatar, content: reply.author.avatar,
radius: 10, radius: 10,
), ),
@ -456,16 +369,9 @@ class _PostFeaturedReplyWidget extends StatelessWidget {
.toList(), .toList(),
), ),
), ),
) ).animate().fadeIn(
.animate()
.fadeIn(
duration: 300.ms, duration: 300.ms,
curve: Curves.easeIn, curve: Curves.easeIn,
)
.paddingOnly(
top: (attachments.length == 1 && !isLargeScreen) ? 10 : 6,
left: (attachments.length == 1 && !isLargeScreen) ? 24 : 60,
right: 16,
); );
}, },
); );
@ -474,8 +380,16 @@ class _PostFeaturedReplyWidget extends StatelessWidget {
class _PostAttachmentWidget extends StatelessWidget { class _PostAttachmentWidget extends StatelessWidget {
final Post item; final Post item;
final EdgeInsets? padding;
final bool isNonScrollAttachment;
final bool isCompact;
const _PostAttachmentWidget({required this.item}); const _PostAttachmentWidget({
required this.item,
required this.padding,
required this.isNonScrollAttachment,
this.isCompact = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -485,25 +399,72 @@ class _PostAttachmentWidget extends StatelessWidget {
? List.from(item.body['attachments']?.whereType<String>()) ? List.from(item.body['attachments']?.whereType<String>())
: List.empty(); : List.empty();
if (attachments.length > 3) { final unFocusColor =
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
if (attachments.isEmpty) return const SizedBox.shrink();
if (isCompact) {
return Row(
children: [
Icon(
Icons.file_copy,
size: 13,
color: unFocusColor,
).paddingOnly(right: 5),
Text(
'attachmentHint'.trParams(
{'count': attachments.length.toString()},
),
style: TextStyle(color: unFocusColor, fontSize: 13),
)
],
).paddingOnly(
left: (padding?.left ?? 0) + 17,
right: (padding?.right ?? 0) + 17,
);
}
if (attachments.length == 1 && !isLargeScreen) {
return AttachmentList( return AttachmentList(
parentId: item.id.toString(), parentId: item.id.toString(),
attachmentsId: attachments, attachmentIds: item.preload == null ? attachments : null,
attachments: item.preload?.attachments,
autoload: false,
isFullWidth: true,
);
} else if (attachments.length > 1 &&
attachments.length % 3 == 0 &&
!isLargeScreen) {
return AttachmentList(
parentId: item.id.toString(),
attachmentIds: item.preload == null ? attachments : null,
attachments: item.preload?.attachments,
autoload: false, autoload: false,
isGrid: true, isGrid: true,
).paddingOnly(left: 36, top: 4, bottom: 4); ).paddingOnly(
} else if (attachments.length > 1 || isLargeScreen) { left: (padding?.left ?? 0) + 14,
right: (padding?.right ?? 0) + 14,
);
} else if (attachments.length == 1 || isNonScrollAttachment) {
return AttachmentList( return AttachmentList(
parentId: item.id.toString(), parentId: item.id.toString(),
attachmentsId: attachments, attachmentIds: item.preload == null ? attachments : null,
attachments: item.preload?.attachments,
autoload: false, autoload: false,
isColumn: true, isColumn: true,
).paddingOnly(left: 60, right: 24, top: 4, bottom: 4); ).paddingOnly(
left: (padding?.left ?? 0) + 14,
right: (padding?.right ?? 0) + 14,
);
} else { } else {
return AttachmentList( return AttachmentList(
flatMaxHeight: MediaQuery.of(context).size.width,
parentId: item.id.toString(), parentId: item.id.toString(),
attachmentsId: attachments, attachmentIds: item.preload == null ? attachments : null,
attachments: item.preload?.attachments,
padding: EdgeInsets.symmetric(
horizontal: (padding?.horizontal ?? 0) + 14,
),
autoload: false, autoload: false,
); );
} }
@ -545,16 +506,17 @@ class _PostEmbedWidget extends StatelessWidget {
size: 16, size: 16,
color: unFocusColor, color: unFocusColor,
), ),
const Gap(6),
Expanded( Expanded(
child: Text( child: Text(
hintText.trParams( hintText.trParams(
{'username': '@$username'}, {'username': '@$username'},
), ),
style: TextStyle(color: unFocusColor), style: TextStyle(color: unFocusColor),
).paddingOnly(left: 6), ),
), ),
], ],
).paddingOnly(left: 12), ).paddingOnly(left: 2),
Card( Card(
elevation: 1, elevation: 1,
child: PostItem( child: PostItem(
@ -574,7 +536,7 @@ class _PostEmbedWidget extends StatelessWidget {
), ),
closedElevation: 0, closedElevation: 0,
openElevation: 0, openElevation: 0,
closedColor: Theme.of(context).colorScheme.surface, closedColor: Colors.transparent,
openColor: Theme.of(context).colorScheme.surface, openColor: Theme.of(context).colorScheme.surface,
); );
} }
@ -590,9 +552,7 @@ class _PostHeaderDividerWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (item.body['description'] != null || item.body['title'] != null) { if (item.body['description'] != null || item.body['title'] != null) {
return const Divider(thickness: 0.3, height: 1).paddingSymmetric( return const SizedBox(height: 8);
vertical: 8,
);
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@ -648,64 +608,104 @@ class _PostFooterWidget extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: widgets, children: widgets,
).paddingOnly(top: 4); ).paddingSymmetric(vertical: 4);
} }
} }
} }
class _PostHeaderWidget extends StatelessWidget { class _PostHeaderWidget extends StatelessWidget {
final bool isCompact; final bool isCompact;
final bool isFullDate;
final Post item; final Post item;
final Function? onTapMore;
const _PostHeaderWidget({ const _PostHeaderWidget({
required this.isCompact, required this.isCompact,
required this.isFullDate,
required this.item, required this.item,
required this.onTapMore,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (isCompact) Row(
AccountAvatar( crossAxisAlignment: CrossAxisAlignment.start,
content: item.author.avatar, children: [
radius: 10, AccountAvatar(
).paddingOnly(left: 2, top: 1), content: item.author.avatar,
Expanded( username: item.author.name,
child: Column( radius: isCompact ? 10 : null,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ Gap(isCompact ? 6 : 8),
Row( Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
item.author.nick, crossAxisAlignment: CrossAxisAlignment.center,
style: const TextStyle(fontWeight: FontWeight.bold), children: [
Text(
item.author.nick,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (isCompact) const Gap(4),
if (isCompact)
RelativeDate(
item.publishedAt?.toLocal() ?? DateTime.now(),
isFull: isFullDate,
).paddingOnly(top: 1),
],
), ),
RelativeDate(item.publishedAt?.toLocal() ?? DateTime.now()) if (!isCompact)
.paddingOnly(left: 4), Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
RelativeDate(
item.publishedAt?.toLocal() ?? DateTime.now(),
isFull: isFullDate,
),
const Gap(4),
Icon(
_PostItemState.visibilityIcons[item.visibility],
size: 16,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.75),
),
],
),
], ],
), ),
if (item.body['title'] != null) ),
Text( if (onTapMore != null)
item.body['title'], IconButton(
style: Theme.of(context) color: Theme.of(context).colorScheme.primary,
.textTheme icon: const Icon(Icons.more_vert),
.bodyMedium! padding: const EdgeInsets.symmetric(horizontal: 4),
.copyWith(fontSize: 15), visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
), ),
if (item.body['description'] != null) onPressed: () => onTapMore!(),
Text( ),
item.body['description'], ],
style: Theme.of(context).textTheme.bodySmall,
),
],
).paddingOnly(left: isCompact ? 6 : 12),
), ),
if (item.type == 'article') const Gap(8),
Badge( if (item.body['title'] != null)
label: Text('article'.tr), Text(
).paddingOnly(top: 3), item.body['title'],
style: Theme.of(context).textTheme.titleMedium,
),
if (item.body['description'] != null)
Text(
item.body['description'],
style: Theme.of(context).textTheme.titleSmall,
),
], ],
); );
} }
@ -736,45 +736,3 @@ class _PostThumbnail extends StatelessWidget {
); );
} }
} }
typedef _OnWidgetSizeChange = void Function(Size size);
class _MeasureSizeRenderObject extends RenderProxyBox {
Size? oldSize;
_OnWidgetSizeChange onChange;
_MeasureSizeRenderObject(this.onChange);
@override
void performLayout() {
super.performLayout();
Size newSize = child!.size;
if (oldSize == newSize) return;
oldSize = newSize;
WidgetsBinding.instance.addPostFrameCallback((_) {
onChange(newSize);
});
}
}
class _MeasureSize extends SingleChildRenderObjectWidget {
final _OnWidgetSizeChange onChange;
const _MeasureSize({
required this.onChange,
required Widget super.child,
});
@override
RenderObject createRenderObject(BuildContext context) {
return _MeasureSizeRenderObject(onChange);
}
@override
void updateRenderObject(
BuildContext context, covariant _MeasureSizeRenderObject renderObject) {
renderObject.onChange = onChange;
}
}

View File

@ -1,8 +1,11 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/widgets/posts/post_action.dart'; import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
@ -12,6 +15,7 @@ class PostListWidget extends StatelessWidget {
final bool isNestedClickable; final bool isNestedClickable;
final PagingController<int, Post> controller; final PagingController<int, Post> controller;
final Color? backgroundColor; final Color? backgroundColor;
final EdgeInsets? padding;
const PostListWidget({ const PostListWidget({
super.key, super.key,
@ -20,6 +24,7 @@ class PostListWidget extends StatelessWidget {
this.isClickable = true, this.isClickable = true,
this.isNestedClickable = true, this.isNestedClickable = true,
this.backgroundColor, this.backgroundColor,
this.padding,
}); });
@override @override
@ -29,16 +34,18 @@ class PostListWidget extends StatelessWidget {
pagingController: controller, pagingController: controller,
builderDelegate: PagedChildBuilderDelegate<Post>( builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) { itemBuilder: (context, item, index) {
return PostListEntryWidget( return Padding(
isShowEmbed: isShowEmbed, padding: padding ?? EdgeInsets.zero,
isNestedClickable: isNestedClickable, child: PostListEntryWidget(
isClickable: isClickable, isShowEmbed: isShowEmbed,
showFeaturedReply: true, isNestedClickable: isNestedClickable,
item: item, isClickable: isClickable,
backgroundColor: backgroundColor, showFeaturedReply: true,
onUpdate: () { item: item,
controller.refresh(); onUpdate: () {
}, controller.refresh();
},
),
); );
}, },
), ),
@ -48,56 +55,126 @@ class PostListWidget extends StatelessWidget {
} }
class PostListEntryWidget extends StatelessWidget { class PostListEntryWidget extends StatelessWidget {
final int renderOrder;
final bool isShowEmbed; final bool isShowEmbed;
final bool isNestedClickable; final bool isNestedClickable;
final bool isClickable; final bool isClickable;
final bool showFeaturedReply; final bool showFeaturedReply;
final Post item; final Post item;
final EdgeInsets? padding;
final Function onUpdate; final Function onUpdate;
final Color? backgroundColor;
const PostListEntryWidget({ const PostListEntryWidget({
super.key, super.key,
this.renderOrder = 0,
required this.isShowEmbed, required this.isShowEmbed,
required this.isNestedClickable, required this.isNestedClickable,
required this.isClickable, required this.isClickable,
required this.showFeaturedReply, required this.showFeaturedReply,
required this.item, required this.item,
this.padding,
required this.onUpdate, required this.onUpdate,
this.backgroundColor,
}); });
void _openActions(BuildContext context) {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(item: item),
).then((value) {
if (value is Future) {
value.then((_) {
onUpdate();
});
} else if (value != null) {
onUpdate();
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return TapRegion(
child: PostItem( child: GestureDetector(
key: Key('p${item.id}'), onLongPress: () => _openActions(context),
item: item, child: PostItem(
isShowEmbed: isShowEmbed, key: Key('p${item.id}'),
isClickable: isNestedClickable, item: item,
showFeaturedReply: showFeaturedReply, isShowEmbed: isShowEmbed,
backgroundColor: backgroundColor, isClickable: isNestedClickable,
).paddingSymmetric(vertical: 8), showFeaturedReply: showFeaturedReply,
onLongPress: () { padding: padding,
final AuthProvider auth = Get.find(); onTapMore: () => _openActions(context),
if (auth.isAuthorized.isFalse) return; onComment: () {
AppRouter.instance
showModalBottomSheet( .pushNamed(
useRootNavigator: true, 'postEditor',
context: context, extra: PostPublishArguments(reply: item),
builder: (context) => PostAction(item: item), )
).then((value) { .then((value) {
if (value is Future) { if (value is Future) {
value.then((_) { value.then((_) {
onUpdate(); onUpdate();
});
} else if (value != null) {
onUpdate();
}
}); });
} else if (value != null) { },
onUpdate(); ).paddingSymmetric(vertical: 8),
} ),
}); onTapInside: (event) {
if (event.buttons == kSecondaryMouseButton) {
_openActions(context);
}
}, },
); );
} }
} }
class ControlledPostListWidget extends StatelessWidget {
final bool isShowEmbed;
final bool isClickable;
final bool isNestedClickable;
final bool isPinned;
final PagingController<int, Post> controller;
final EdgeInsets? padding;
final Function? onUpdate;
const ControlledPostListWidget({
super.key,
required this.controller,
this.isShowEmbed = true,
this.isClickable = true,
this.isNestedClickable = true,
this.isPinned = true,
this.padding,
this.onUpdate,
});
@override
Widget build(BuildContext context) {
return PagedSliverList<int, Post>.separated(
addRepaintBoundaries: true,
pagingController: controller,
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) {
if (item.pinnedAt != null && !isPinned) {
return const SizedBox.shrink();
}
return PostListEntryWidget(
isShowEmbed: isShowEmbed,
isNestedClickable: isNestedClickable,
isClickable: isClickable,
showFeaturedReply: true,
padding: padding,
item: item,
onUpdate: onUpdate ?? () {},
);
},
),
separatorBuilder: (_, __) => const Divider(thickness: 0.3, height: 0.3),
);
}
}

View File

@ -1,43 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/post.dart';
import 'package:solian/widgets/posts/post_item.dart';
class PostOwnedListEntry extends StatelessWidget {
final Post item;
final Function onTap;
final bool isFullContent;
final Color? backgroundColor;
const PostOwnedListEntry({
super.key,
required this.item,
required this.onTap,
this.isFullContent = false,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
return Card(
child: GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PostItem(
key: Key('p${item.id}'),
item: item,
isShowEmbed: false,
isClickable: false,
isShowReply: false,
isReactable: false,
isFullContent: isFullContent,
backgroundColor: backgroundColor,
).paddingSymmetric(vertical: 8),
],
),
onTap: () => onTap(),
),
);
}
}

View File

@ -11,6 +11,7 @@ class PostQuickAction extends StatefulWidget {
final Post item; final Post item;
final bool isReactable; final bool isReactable;
final bool isShowReply; final bool isShowReply;
final Function onComment;
final void Function(String symbol, int num) onReact; final void Function(String symbol, int num) onReact;
const PostQuickAction({ const PostQuickAction({
@ -18,6 +19,7 @@ class PostQuickAction extends StatefulWidget {
required this.item, required this.item,
this.isShowReply = true, this.isShowReply = true,
this.isReactable = true, this.isReactable = true,
required this.onComment,
required this.onReact, required this.onReact,
}); });
@ -106,7 +108,11 @@ class _PostQuickActionState extends State<PostQuickAction> {
builder: (context) { builder: (context) {
return PostReplyListPopup(item: widget.item); return PostReplyListPopup(item: widget.item);
}, },
); ).then((signal) {
if (signal == true) {
widget.onComment();
}
});
}, },
), ),
), ),

View File

@ -8,11 +8,13 @@ import 'package:solian/widgets/posts/post_list.dart';
class PostReplyList extends StatefulWidget { class PostReplyList extends StatefulWidget {
final Post item; final Post item;
final EdgeInsets? padding;
final Color? backgroundColor; final Color? backgroundColor;
const PostReplyList({ const PostReplyList({
super.key, super.key,
required this.item, required this.item,
this.padding,
this.backgroundColor, this.backgroundColor,
}); });
@ -53,6 +55,7 @@ class _PostReplyListState extends State<PostReplyList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PostListWidget( return PostListWidget(
padding: widget.padding,
isShowEmbed: false, isShowEmbed: false,
controller: _pagingController, controller: _pagingController,
backgroundColor: widget.backgroundColor, backgroundColor: widget.backgroundColor,
@ -70,16 +73,31 @@ class PostReplyListPopup extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
'postReplies'.tr, children: [
style: Theme.of(context).textTheme.headlineSmall, Expanded(
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), child: Text(
'postReplies'.tr,
style: Theme.of(context).textTheme.headlineSmall,
),
),
IconButton(
icon: const Icon(Icons.add_comment),
visualDensity: const VisualDensity(horizontal: -4),
onPressed: () {
Navigator.pop(context, true);
},
),
],
).paddingOnly(left: 24, right: 24, top: 24, bottom: 8),
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
PostReplyList( PostReplyList(
item: item, item: item,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow, padding: EdgeInsets.symmetric(horizontal: 10),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
), ),
], ],
), ),

View File

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:solian/models/post.dart';
import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/root_container.dart';
class PostShareImage extends StatelessWidget {
final Post item;
const PostShareImage({super.key, required this.item});
@override
Widget build(BuildContext context) {
final textColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.3);
return RootContainer(
child: Wrap(
alignment: WrapAlignment.spaceBetween,
runAlignment: WrapAlignment.center,
children: [
const SizedBox(height: 40),
Material(
color: Colors.transparent,
child: Card(
margin: EdgeInsets.zero,
child: PostItem(
item: item,
isShowEmbed: true,
isClickable: false,
showFeaturedReply: false,
isReactable: false,
isShowReply: false,
isNonScrollAttachment: true,
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 16,
),
onComment: () {},
),
),
).paddingOnly(bottom: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset(
'assets/logo.png',
width: 48,
height: 48,
),
),
const Gap(16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'shareImageFooter'.tr,
style: TextStyle(
fontSize: 13,
color: textColor,
),
),
Text(
'Solsynth LLC © ${DateTime.now().year}',
style: TextStyle(
fontSize: 11,
color: textColor,
),
),
],
),
],
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: QrImageView(
data: 'https://solsynth.dev/posts/${item.id}',
version: QrVersions.auto,
padding: const EdgeInsets.all(4),
size: 48,
dataModuleStyle: QrDataModuleStyle(
color: Theme.of(context).colorScheme.onSurface,
),
eyeStyle: QrEyeStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
],
),
],
).paddingSymmetric(horizontal: 36, vertical: 24),
);
}
}

View File

@ -25,7 +25,6 @@ class PostSingleDisplay extends StatelessWidget {
isNestedClickable: true, isNestedClickable: true,
showFeaturedReply: true, showFeaturedReply: true,
onUpdate: onUpdate, onUpdate: onUpdate,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
), ),
), ),
), ),

View File

@ -1,48 +0,0 @@
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/widgets/posts/post_list.dart';
class PostWarpedListWidget extends StatelessWidget {
final bool isShowEmbed;
final bool isClickable;
final bool isNestedClickable;
final bool isPinned;
final PagingController<int, Post> controller;
final Function? onUpdate;
const PostWarpedListWidget({
super.key,
required this.controller,
this.isShowEmbed = true,
this.isClickable = true,
this.isNestedClickable = true,
this.isPinned = true,
this.onUpdate,
});
@override
Widget build(BuildContext context) {
return PagedSliverList<int, Post>.separated(
addRepaintBoundaries: true,
pagingController: controller,
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) {
if (item.pinnedAt != null && !isPinned) {
return const SizedBox.shrink();
}
return PostListEntryWidget(
renderOrder: index,
isShowEmbed: isShowEmbed,
isNestedClickable: isNestedClickable,
isClickable: isClickable,
showFeaturedReply: true,
item: item,
onUpdate: onUpdate ?? () {},
);
},
),
separatorBuilder: (_, __) => const Divider(thickness: 0.3, height: 0.3),
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
@ -8,6 +7,7 @@ import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart'; import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/account/relative_select.dart'; import 'package:solian/widgets/account/relative_select.dart';
import 'package:solian/widgets/loading_indicator.dart';
class RealmMemberListPopup extends StatefulWidget { class RealmMemberListPopup extends StatefulWidget {
final Realm realm; final Realm realm;
@ -128,7 +128,7 @@ class _RealmMemberListPopupState extends State<RealmMemberListPopup> {
'realmMembers'.tr, 'realmMembers'.tr,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh, tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 20), contentPadding: const EdgeInsets.symmetric(horizontal: 20),
@ -149,7 +149,8 @@ class _RealmMemberListPopupState extends State<RealmMemberListPopup> {
title: Text(element.account.nick), title: Text(element.account.nick),
subtitle: Text(element.account.name), subtitle: Text(element.account.name),
leading: GestureDetector( leading: GestureDetector(
child: AccountAvatar(content: element.account.avatar), child:
AttachedCircleAvatar(content: element.account.avatar),
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,

Some files were not shown because too many files have changed in this diff Show More