Compare commits

...

98 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
11c913af60 🚀 Launch 1.3.6+1 2024-10-06 11:34:12 +08:00
db8f0d63e1 🐛 Fix responsive chat issue 2024-10-06 11:12:54 +08:00
4036a79995 🐛 Fix some building time problem 2024-10-06 01:53:36 +08:00
859bbd09e0 🚀 Launch 1.3.0+1 2024-10-06 01:43:10 +08:00
60033fdef3 🐛 Fix platform specific bugs & crashes 2024-10-06 01:42:51 +08:00
9c3d181deb 📱 Optimize the call experience on landscape device 2024-10-06 01:25:10 +08:00
9e6829bd5a 📱 New layout for the landscape device 2024-10-06 01:17:49 +08:00
f50461a7f7 💄 Better chat list 2024-10-05 23:12:23 +08:00
147879e4d8 Better last message preview 2024-10-05 15:11:48 +08:00
f353c05cb5 💄 Better way to switch focused realm 2024-10-05 14:25:57 +08:00
ac60043ca7 🐛 Bug fixes 2024-10-05 03:38:30 +08:00
8d79274b0c 🐛 Fix dm channel display error with deleted user 2024-10-05 03:21:53 +08:00
ad4e4071fa ♻️ Use bottom navigation bar instead 2024-10-05 03:14:52 +08:00
c59f77c877 🐛 Fix windows rendering lack 2024-09-28 18:41:56 +08:00
16047a7d57 🚀 Launch 1.2.5+1 2024-09-27 00:20:04 +08:00
fdc68fc5e1 💄 Optimize attachment editor controls 2024-09-27 00:12:30 +08:00
bbee825cf4 ♻️ Refactor profile page code 2024-09-27 00:02:08 +08:00
2673c11046 Able to block anyone
💄 Optimize user profile page
2024-09-26 23:47:19 +08:00
3ac6822ab6 🚀 Launch 1.2.4+1 2024-09-24 22:40:54 +08:00
7a5fd2e468 In app rating 2024-09-24 22:10:45 +08:00
e1ddd22e4e 🚀 Launch 1.2.3+2 2024-09-23 23:34:40 +08:00
22b2ae32e9 Featured replies clickable 2024-09-23 23:34:25 +08:00
9d5c452eae 🐛 Fix overflow in content 2024-09-23 23:20:01 +08:00
0fdb1e4ead 💫 Improve loading image animation 2024-09-23 23:19:52 +08:00
724bd6592e 💄 Improvements and optimize UX 2024-09-23 22:43:13 +08:00
2d347e0d41 ♻️ Refactored post item widget 2024-09-23 22:43:02 +08:00
de39799301 🚀 Launch 1.2.3 2024-09-22 22:57:00 +08:00
4b921602a2 🐛 Bug fixes 2024-09-22 22:56:28 +08:00
135 changed files with 9007 additions and 4879 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

@ -4,3 +4,4 @@ android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
android.nonFinalResIds=false android.nonFinalResIds=false
kotlin.jvm.target.validation.mode = IGNORE

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

@ -22,9 +22,9 @@
"explore": "Explore", "explore": "Explore",
"posts": "Posts", "posts": "Posts",
"unlink": "Unlink", "unlink": "Unlink",
"feedSearch": "Search Feed", "postSearch": "Search Post",
"feedSearchWithTag": "Searching with tag #@key", "postSearchWithTag": "Searching with tag #@key",
"feedSearchWithCategory": "Searching in category @category", "postSearchWithCategory": "Searching in category @category",
"feedUnreadCount": "@count posts you may missed", "feedUnreadCount": "@count posts you may missed",
"messages": "Messages", "messages": "Messages",
"messagesUnreadCount": "@count messages unread", "messagesUnreadCount": "@count messages unread",
@ -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.",
@ -98,6 +99,8 @@
"accountFriendBlocked": "Friend blocklist", "accountFriendBlocked": "Friend blocklist",
"accountFriendListHint": "Swipe left to decline, right to approve", "accountFriendListHint": "Swipe left to decline, right to approve",
"accountFriendRequestSent": "Friend request sent, waiting for processing...", "accountFriendRequestSent": "Friend request sent, waiting for processing...",
"accountBlocked": "Account has been blocked",
"accountUnblocked": "Account has been unblocked",
"accountSuspended": "Account was suspended", "accountSuspended": "Account was suspended",
"accountSuspendedAt": "Account was suspended since @date", "accountSuspendedAt": "Account was suspended since @date",
"aspectRatio": "Aspect Ratio", "aspectRatio": "Aspect Ratio",
@ -138,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",
@ -364,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",
@ -433,6 +437,7 @@
"updateCheckStrictly": "Strict mode", "updateCheckStrictly": "Strict mode",
"updateCheckStrictlyDesc": "If enabled, the app will ask for updating once the local version is different from remote one.", "updateCheckStrictlyDesc": "If enabled, the app will ask for updating once the local version is different from remote one.",
"updateMayAvailable": "App version @version is available, you can update from app store or our website.", "updateMayAvailable": "App version @version is available, you can update from app store or our website.",
"updateNow": "Update now",
"termAccept": "I've read and agree to Solar Network's Terms", "termAccept": "I've read and agree to Solar Network's Terms",
"termAcceptDesc": "Including but not limited to \"User Agreement\" and \"Privacy Policy\"", "termAcceptDesc": "Including but not limited to \"User Agreement\" and \"Privacy Policy\"",
"termAcceptLink": "View terms", "termAcceptLink": "View terms",
@ -452,5 +457,42 @@
"accountDeletionConfirm": "Confirm request account deletion", "accountDeletionConfirm": "Confirm request account deletion",
"accountDeletionConfirmDesc": "Are you sure to delete account @account? You will receive a confirmation email with a link to confirm the deletion of the account within 24 hours. Note that this action is irreversible, and all data associated with the account will be deleted, and you should be careful about it.", "accountDeletionConfirmDesc": "Are you sure to delete account @account? You will receive a confirmation email with a link to confirm the deletion of the account within 24 hours. Note that this action is irreversible, and all data associated with the account will be deleted, and you should be careful about it.",
"accountDeletionRequested": "Account deletion requested, check your inbox to confirm the request.", "accountDeletionRequested": "Account deletion requested, check your inbox to confirm the request.",
"slideToConfirm": "Slide to confirm" "slideToConfirm": "Slide to confirm",
"serviceStatus": "Status of Service",
"firstBootTime": "First boot at @time",
"rateTheApp": "Rate the app",
"rateTheAppDesc": "Rate Solar Network on the App Store to let us serve you better!",
"friendAdd": "Add as friend",
"blockUser": "Block user",
"unblockUser": "Unblock user",
"learnMoreAboutPerson": "Learn more about that person",
"global": "Global",
"all": "All",
"unablePreview": "Unable to preview",
"dashboardNav": "Dash",
"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": "在设备常驻一个通知服务,使得部分不支持推送通知的设备可以在后台收到通知;启用该功能的情况下不会向服务器注册推送通知,并且你会始终在他人眼中成为在线(隐身除外);可能需要在设置中关闭电量与流量优化。",
@ -32,9 +33,9 @@
"dashboard": "仪表盘", "dashboard": "仪表盘",
"today": "今日", "today": "今日",
"yesterday": "昨日", "yesterday": "昨日",
"feedSearch": "搜索资讯", "postSearch": "搜索帖子",
"feedSearchWithTag": "检索带有 #@key 标签的资讯", "postSearchWithTag": "检索带有 #@key 标签的资讯",
"feedSearchWithCategory": "检索位于分类 @category 的资讯", "postSearchWithCategory": "检索位于分类 @category 的资讯",
"feedUnreadCount": "@count 条你可能错过的帖子", "feedUnreadCount": "@count 条你可能错过的帖子",
"messages": "消息", "messages": "消息",
"messagesUnreadCount": "@count 条未读的消息", "messagesUnreadCount": "@count 条未读的消息",
@ -98,6 +99,8 @@
"accountFriendBlocked": "好友黑名单", "accountFriendBlocked": "好友黑名单",
"accountFriendListHint": "左滑来拒绝,右滑来接受", "accountFriendListHint": "左滑来拒绝,右滑来接受",
"accountFriendRequestSent": "好友请求已发送,等待处理对方中……", "accountFriendRequestSent": "好友请求已发送,等待处理对方中……",
"accountBlocked": "已屏蔽账号",
"accountUnblocked": "已解除屏蔽账号",
"accountSuspended": "帐号被停用", "accountSuspended": "帐号被停用",
"accountSuspendedAt": "该帐号自 @date 起被停用", "accountSuspendedAt": "该帐号自 @date 起被停用",
"aspectRatio": "纵横比", "aspectRatio": "纵横比",
@ -264,7 +267,7 @@
"channelMembersAddHint": "到 @channel", "channelMembersAddHint": "到 @channel",
"channelType": "频道类型", "channelType": "频道类型",
"channelTypeCommon": "普通频道", "channelTypeCommon": "普通频道",
"channelTypeDirect": "私信聊天", "channelTypeDirect": "私信",
"channelAdjust": "调整频道", "channelAdjust": "调整频道",
"channelDetail": "频道详情", "channelDetail": "频道详情",
"channelSettings": "频道设置", "channelSettings": "频道设置",
@ -360,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": "经典蓝",
@ -428,6 +432,7 @@
"update": "更新", "update": "更新",
"updateCheckStrictly": "严格模式", "updateCheckStrictly": "严格模式",
"updateCheckStrictlyDesc": "如果启用,应用程序将会在本地版本与远程版本不同时询问更新,而不会检查版本号大小。", "updateCheckStrictlyDesc": "如果启用,应用程序将会在本地版本与远程版本不同时询问更新,而不会检查版本号大小。",
"updateNow": "立即更新",
"updateMayAvailable": "版本 @version 现已可用,你可以前往应用商店或是我们的官网下载更新。", "updateMayAvailable": "版本 @version 现已可用,你可以前往应用商店或是我们的官网下载更新。",
"termAccept": "我已阅读并同意 Solar Network 各项条款", "termAccept": "我已阅读并同意 Solar Network 各项条款",
"termAcceptDesc": "包括但不限于《用户守则》和《隐私政策》", "termAcceptDesc": "包括但不限于《用户守则》和《隐私政策》",
@ -448,5 +453,42 @@
"accountDeletionConfirm": "确认账号删除请求", "accountDeletionConfirm": "确认账号删除请求",
"accountDeletionConfirmDesc": "你确定要删除账号 @account 吗?你将会在其绑定的主要邮件地址收到一封包含着确认删除账号连接的邮件,在二十四小时内使用该连接即可完成删除账号。注意,本操作不可撤销,并且账号创建或关联的所有数据都将被删除,请三思而后行。", "accountDeletionConfirmDesc": "你确定要删除账号 @account 吗?你将会在其绑定的主要邮件地址收到一封包含着确认删除账号连接的邮件,在二十四小时内使用该连接即可完成删除账号。注意,本操作不可撤销,并且账号创建或关联的所有数据都将被删除,请三思而后行。",
"accountDeletionRequested": "已请求删除账号,检查你的收件箱来确认请求。", "accountDeletionRequested": "已请求删除账号,检查你的收件箱来确认请求。",
"slideToConfirm": "滑动来确认" "slideToConfirm": "滑动来确认",
"serviceStatus": "服务状态",
"firstBootTime": "首次启动于 @time",
"rateTheApp": "给应用评分",
"rateTheAppDesc": "在 App Store 上给 Solar Network 评分,让我们更好地为您服务吧!",
"friendAdd": "添加好友",
"blockUser": "屏蔽用户",
"unblockUser": "解除屏蔽用户",
"learnMoreAboutPerson": "了解关于 TA 的更多",
"global": "全局",
"all": "全部",
"unablePreview": "无法预览",
"dashboardNav": "仪表盘",
"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)
@ -227,6 +232,8 @@ PODS:
- TOCropViewController (~> 2.7.4) - TOCropViewController (~> 2.7.4)
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- in_app_review (0.2.0):
- Flutter
- livekit_client (2.2.6): - livekit_client (2.2.6):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.04) - WebRTC-SDK (= 125.6422.04)
@ -257,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):
@ -267,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)":
@ -302,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`)
@ -314,10 +323,12 @@ 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`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`) - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`) - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
@ -331,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`)
@ -361,6 +372,7 @@ SPEC REPOS:
- nanopb - nanopb
- PromisesObjC - PromisesObjC
- PromisesSwift - PromisesSwift
- SAMKeychain
- SDWebImage - SDWebImage
- sqlite3 - sqlite3
- SwiftyGif - SwiftyGif
@ -374,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:
@ -398,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:
@ -406,6 +422,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/image_cropper/ios" :path: ".symlinks/plugins/image_cropper/ios"
image_picker_ios: image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
livekit_client: livekit_client:
:path: ".symlinks/plugins/livekit_client/ios" :path: ".symlinks/plugins/livekit_client/ios"
media_kit_libs_ios_video: media_kit_libs_ios_video:
@ -432,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:
@ -444,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
@ -475,19 +494,21 @@ 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
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
livekit_client: 20e01637431bc108dad451c8a11c1d206e1dd2cd livekit_client: 20e01637431bc108dad451c8a11c1d206e1dd2cd
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
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
@ -495,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

@ -81,6 +81,10 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
</array>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<false/> <false/>
</dict> </dict>

View File

@ -1,25 +1,35 @@
import 'dart:async'; import 'dart:async';
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';
import 'package:in_app_review/in_app_review.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.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/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/channel.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;
@ -40,8 +50,54 @@ 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 {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('first_boot_time')) {
final rawTime = prefs.getString('first_boot_time');
final time = DateTime.tryParse(rawTime ?? '');
if (time != null &&
time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
final inAppReview = InAppReview.instance;
if (prefs.getBool('rating_requested') == true) return;
if (await inAppReview.isAvailable()) {
await inAppReview.requestReview();
prefs.setBool('rating_requested', true);
} else {
log('Unable request app review, unavailable');
}
}
} else {
prefs.setString('first_boot_time', DateTime.now().toIso8601String());
}
}
void _updateNow(String localVersionString, String remoteVersionString) {
context
.showConfirmDialog(
'updateAvailable'.tr,
'updateAvailableDesc'.trParams({
'from': localVersionString,
'to': remoteVersionString,
}),
)
.then((result) {
if (result) {
final model = UpdateModel(
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
'solian-app-arm64-v8a-release.apk',
'ic_launcher',
'https://testflight.apple.com/join/YJ0lmN6O',
);
AzhonAppUpdate.update(model);
}
});
}
Future<void> _checkForUpdate() async { Future<void> _checkForUpdate() async {
if (PlatformInfo.isWeb) return; if (PlatformInfo.isWeb) return;
try { try {
@ -70,25 +126,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
remoteBuildNumber > localBuildNumber) || remoteBuildNumber > localBuildNumber) ||
(remoteVersionString != localVersionString && strictUpdate)) { (remoteVersionString != localVersionString && strictUpdate)) {
if (PlatformInfo.isAndroid) { if (PlatformInfo.isAndroid) {
context _updateNow(localVersionString, remoteVersionString);
.showConfirmDialog(
'updateAvailable'.tr,
'updateAvailableDesc'.trParams({
'from': localVersionString,
'to': remoteVersionString,
}),
)
.then((result) {
if (result) {
final model = UpdateModel(
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
'solian-app-arm64-v8a-release.apk',
'ic_launcher',
'https://testflight.apple.com/join/YJ0lmN6O',
);
AzhonAppUpdate.update(model);
}
});
} else { } else {
context.showInfoDialog( context.showInfoDialog(
'updateAvailable'.tr, 'updateAvailable'.tr,
@ -97,9 +135,19 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
} }
} else if (remoteVersionString != localVersionString) { } else if (remoteVersionString != localVersionString) {
_bootCompleter.future.then((_) { _bootCompleter.future.then((_) {
context.showSnackbar('updateMayAvailable'.trParams({ context.showSnackbar(
'updateMayAvailable'.trParams({
'version': remoteVersionString, 'version': remoteVersionString,
})); }),
action: PlatformInfo.isAndroid
? SnackBarAction(
label: 'updateNow'.tr,
onPressed: () {
_updateNow(localVersionString, remoteVersionString);
},
)
: null,
);
}); });
} }
} catch (e) { } catch (e) {
@ -162,12 +210,25 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
try { try {
await Future.wait([ await Future.wait([
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
Get.find<ChannelProvider>().refreshAvailableChannel(), 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);
} }
@ -179,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()}),
@ -212,14 +273,16 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
super.initState(); super.initState();
_runPeriods(); _runPeriods();
_checkForUpdate(); _checkForUpdate();
_bootCompleter.future.then((_) {
_requestRating();
});
} }
@override @override
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,
@ -315,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';
@ -57,6 +58,8 @@ void main() async {
Future<void> _initializeFirebase() async { Future<void> _initializeFirebase() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
if (PlatformInfo.isIOS || PlatformInfo.isAndroid || PlatformInfo.isMacOS) {
// Initialize firebase crashlytics for the platform that supported
FlutterError.onError = (errorDetails) { FlutterError.onError = (errorDetails) {
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails); FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
}; };
@ -65,6 +68,7 @@ Future<void> _initializeFirebase() async {
return true; return true;
}; };
} }
}
Future<void> _initializeBackgroundNotificationService() async { Future<void> _initializeBackgroundNotificationService() async {
autoConfigureBackgroundNotificationService(); autoConfigureBackgroundNotificationService();
@ -135,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());
@ -151,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

@ -27,6 +27,10 @@ abstract class PlatformInfo {
static bool get canCacheImage => isAndroid || isIOS || isMacOS; static bool get canCacheImage => isAndroid || isIOS || isMacOS;
static bool get canRateTheApp => isIOS || isMacOS;
static bool get canCropImage => isIOS || isAndroid || isWeb;
static bool get canRecord => (isMobile || isMacOS); static bool get canRecord => (isMobile || isMacOS);
static bool get canPushNotification => isAndroid || isIOS || isMacOS; static bool get canPushNotification => isAndroid || isIOS || isMacOS;

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

@ -392,7 +392,7 @@ class ChatCallProvider extends GetxController {
} }
Future gotoScreen(BuildContext context) { Future gotoScreen(BuildContext context) {
return Navigator.of(context, rootNavigator: true).push( return Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const CallScreen()), MaterialPageRoute(builder: (context) => const CallScreen()),
); );
} }

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,6 +56,7 @@ class AttachmentProvider extends GetConnect {
} }
} }
if (pendingQuery.isNotEmpty) {
final resp = await get( final resp = await get(
'/attachments?take=${pendingQuery.length}&id=${pendingQuery.join(',')}', '/attachments?take=${pendingQuery.length}&id=${pendingQuery.join(',')}',
); );
@ -63,6 +79,7 @@ class AttachmentProvider extends GetConnect {
} }
} }
} }
}
return result; return result;
} }

View File

@ -9,25 +9,6 @@ import 'package:uuid/uuid.dart';
class ChannelProvider extends GetxController { class ChannelProvider extends GetxController {
RxBool isLoading = false.obs; RxBool isLoading = false.obs;
RxList<Channel> availableChannels = RxList.empty(growable: true);
List<Channel> get groupChannels =>
availableChannels.where((x) => x.type == 0).toList();
List<Channel> get directChannels =>
availableChannels.where((x) => x.type == 1).toList();
Future<void> refreshAvailableChannel() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
isLoading.value = true;
final resp = await listAvailableChannel();
isLoading.value = false;
availableChannels.value =
resp.body.map((x) => Channel.fromJson(x)).toList().cast<Channel>();
availableChannels.refresh();
}
Future<Response> getChannel(String alias, {String realm = 'global'}) async { Future<Response> getChannel(String alias, {String realm = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
@ -89,18 +70,22 @@ class ChannelProvider extends GetxController {
return resp; return resp;
} }
Future<Response> listAvailableChannel({String scope = 'global'}) async { Future<List<Channel>> listAvailableChannel({
String scope = 'global',
bool isDirect = false,
}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = await auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$scope/me/available'); final resp =
await client.get('/channels/$scope/me/available?direct=$isDirect');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
return resp; return List.from(resp.body.map((x) => Channel.fromJson(x)));
} }
Future<Response> createChannel(String scope, dynamic payload) async { Future<Response> createChannel(String scope, dynamic payload) async {

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

@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:get/get.dart' hide Value; import 'package:get/get.dart' hide Value;
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
@ -182,4 +185,26 @@ class MessagesFetchingProvider extends GetxController {
..orderBy([(t) => OrderingTerm.desc(t.id)])) ..orderBy([(t) => OrderingTerm.desc(t.id)]))
.getSingleOrNull(); .getSingleOrNull();
} }
Future<Map<int, List<LocalMessageEventTableData>>>
getLastInAllChannels() async {
final database = Get.find<DatabaseProvider>().database;
final rows = await database.customSelect('''
SELECT id, channel_id, data, created_at
FROM ${database.localMessageEventTable.actualTableName}
WHERE (channel_id, created_at) IN (
SELECT channel_id, MAX(created_at)
FROM ${database.localMessageEventTable.actualTableName}
GROUP BY channel_id
)
''', readsFrom: {database.localMessageEventTable}).get();
return rows.map((row) {
return LocalMessageEventTableData(
id: row.read<int>('id'),
channelId: row.read<int>('channel_id'),
data: Event.fromJson(jsonDecode(row.read<String>('data'))),
createdAt: row.read<DateTime>('created_at'),
);
}).groupListsBy((x) => x.channelId);
}
} }

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

@ -26,6 +26,19 @@ class RelationshipProvider extends GetxController {
return _friends.any((x) => x.relatedId == account.id); return _friends.any((x) => x.relatedId == account.id);
} }
Future<Relationship?> getRelationship(int relatedId) async {
final AuthProvider auth = Get.find();
final client = await auth.configureClient('auth');
final resp = await client.get('/users/me/relations/$relatedId');
if (resp.statusCode == 404) {
return null;
} else if (resp.statusCode != 200) {
throw RequestException(resp);
}
return Relationship.fromJson(resp.body);
}
Future<Response> listRelation() async { Future<Response> listRelation() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = await auth.configureClient('auth'); final client = await auth.configureClient('auth');
@ -38,7 +51,19 @@ class RelationshipProvider extends GetxController {
return client.get('/users/me/relations?status=$status'); return client.get('/users/me/relations?status=$status');
} }
Future<Response> makeFriend(String username) async { Future<Relationship?> blockUser(String username) async {
final AuthProvider auth = Get.find();
final client = await auth.configureClient('auth');
final resp =
await client.post('/users/me/relations/block?related=$username', {});
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return Relationship.fromJson(resp.body);
}
Future<Relationship?> makeFriend(String username) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = await auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.post('/users/me/relations?related=$username', {}); final resp = await client.post('/users/me/relations?related=$username', {});
@ -46,7 +71,7 @@ class RelationshipProvider extends GetxController {
throw RequestException(resp); throw RequestException(resp);
} }
return resp; return Relationship.fromJson(resp.body);
} }
Future<Response> handleRelation( Future<Response> handleRelation(
@ -64,17 +89,17 @@ class RelationshipProvider extends GetxController {
return resp; return resp;
} }
Future<Response> editRelation(Relationship relationship, int status) async { Future<Relationship?> editRelation(int relatedId, int status) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = await auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.patch( final resp = await client.put(
'/users/me/relations/${relationship.relatedId}', '/users/me/relations/$relatedId',
{'status': status}, {'status': status},
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
return resp; return Relationship.fromJson(resp.body);
} }
} }

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,18 +19,20 @@ 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';
import 'package:solian/screens/realms/realm_view.dart'; import 'package:solian/screens/realms/realm_view.dart';
import 'package:solian/screens/feed.dart'; import 'package:solian/screens/explore.dart';
import 'package:solian/screens/posts/post_editor.dart'; import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/screens/settings.dart'; import 'package:solian/screens/settings.dart';
import 'package:solian/shells/root_shell.dart'; import 'package:solian/shells/root_shell.dart';
import 'package:solian/shells/title_shell.dart'; import 'package:solian/shells/title_shell.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/sidebar/empty_placeholder.dart';
abstract class AppRouter { abstract class AppRouter {
static GoRouter instance = GoRouter( static GoRouter instance = GoRouter(
@ -78,26 +83,26 @@ abstract class AppRouter {
builder: (context, state, child) => child, builder: (context, state, child) => child,
routes: [ routes: [
GoRoute( GoRoute(
path: '/feed', path: '/explore',
name: 'feed', name: 'explore',
builder: (context, state) => const FeedScreen(), builder: (context, state) => const ExploreScreen(),
),
GoRoute(
path: '/feed/search',
name: 'feedSearch',
builder: (context, state) => TitleShell(
state: state,
child: FeedSearchScreen(
tag: state.uri.queryParameters['tag'],
category: state.uri.queryParameters['category'],
),
),
), ),
GoRoute( GoRoute(
path: '/drafts', path: '/drafts',
name: 'draftBox', name: 'draftBox',
builder: (context, state) => const DraftBoxScreen(), builder: (context, state) => const DraftBoxScreen(),
), ),
GoRoute(
path: '/posts/search',
name: 'postSearch',
builder: (context, state) => TitleShell(
state: state,
child: PostSearchScreen(
tag: state.uri.queryParameters['tag'],
category: state.uri.queryParameters['category'],
),
),
),
GoRoute( GoRoute(
path: '/posts/view/:id', path: '/posts/view/:id',
name: 'postDetail', name: 'postDetail',
@ -105,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?,
), ),
), ),
), ),
@ -137,12 +143,15 @@ abstract class AppRouter {
); );
static final ShellRoute _chatRoute = ShellRoute( static final ShellRoute _chatRoute = ShellRoute(
builder: (context, state, child) => child, builder: (context, state, child) =>
AppTheme.isLargeScreen(context) ? ChatListShell(child: child) : child,
routes: [ routes: [
GoRoute( GoRoute(
path: '/chat', path: '/chat',
name: 'chat', name: 'chat',
builder: (context, state) => const ChatScreen(), builder: (context, state) => AppTheme.isLargeScreen(context)
? const EmptyPagePlaceholder()
: const ChatScreen(),
), ),
GoRoute( GoRoute(
path: '/chat/organize', path: '/chat/organize',
@ -173,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,
@ -258,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

@ -1,7 +1,9 @@
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';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.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';
@ -13,9 +15,7 @@ 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,
child: SizedBox(
width: double.infinity, width: double.infinity,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -52,8 +52,9 @@ class AboutScreen extends StatelessWidget {
CenteredContainer( CenteredContainer(
maxWidth: 280, maxWidth: 280,
child: Wrap( child: Wrap(
spacing: 8, spacing: 4,
runSpacing: 8, runSpacing: 4,
alignment: WrapAlignment.center,
children: [ children: [
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
@ -91,6 +92,13 @@ class AboutScreen extends StatelessWidget {
launchUrlString('https://solsynth.dev/terms'); launchUrlString('https://solsynth.dev/terms');
}, },
), ),
TextButton(
style: denseButtonStyle,
child: Text('serviceStatus'.tr),
onPressed: () {
launchUrlString('https://status.solsynth.dev');
},
),
], ],
), ),
), ),
@ -102,8 +110,35 @@ class AboutScreen extends StatelessWidget {
fontSize: 12, fontSize: 12,
), ),
), ),
], FutureBuilder(
future: SharedPreferences.getInstance(),
builder: (context, snapshot) {
const textStyle = TextStyle(
fontWeight: FontWeight.w300,
fontSize: 12,
);
if (!snapshot.hasData ||
!snapshot.data!.containsKey('first_boot_time')) {
return Text(
'firstBootTime'.trParams({'time': 'unknown'.tr}),
style: textStyle,
);
} else {
return Text(
'firstBootTime'.trParams({
'time': DateFormat('yyyy-MM-dd').format(
DateTime.tryParse(
snapshot.data!.getString('first_boot_time')!,
)?.toLocal() ??
DateTime.now(),
), ),
}),
style: textStyle,
);
}
},
),
],
), ),
); );
} }

View File

@ -49,9 +49,7 @@ 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: SafeArea(
child: Obx(() { child: Obx(() {
if (auth.isAuthorized.isFalse) { if (auth.isAuthorized.isFalse) {
return Center( return Center(
@ -108,7 +106,7 @@ class _AccountScreenState extends State<AccountScreen> {
child: ListView( child: ListView(
children: [ children: [
if (auth.userProfile.value != null) if (auth.userProfile.value != null)
const AccountHeading().paddingOnly(bottom: 8, top: 8), const AccountHeading().paddingOnly(bottom: 8, top: 16),
...(actionItems.map( ...(actionItems.map(
(x) => ListTile( (x) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34), contentPadding: const EdgeInsets.symmetric(horizontal: 34),
@ -131,6 +129,24 @@ class _AccountScreenState extends State<AccountScreen> {
AppRouter.instance.pushNamed('settings'); 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) if (auth.isAuthorized.value)
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34), contentPadding: const EdgeInsets.symmetric(horizontal: 34),
@ -155,7 +171,6 @@ class _AccountScreenState extends State<AccountScreen> {
), ),
); );
}), }),
),
); );
} }
} }

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,18 +34,24 @@ 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(
onRefresh: () => nty.fetchNotification(),
child: CustomScrollView(
slivers: [ slivers: [
if (_isBusy)
SliverToBoxAdapter( SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(), child: LoadingIndicator(
isActive: nty.isBusy.value,
), ),
if (ws.notifications.isEmpty) ),
if (nty.notifications
.where((x) => x.readAt == null)
.isEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10), padding: const EdgeInsets.symmetric(horizontal: 10),
color: color: Theme.of(context)
Theme.of(context).colorScheme.surfaceContainerHigh, .colorScheme
.surfaceContainerHigh,
child: ListTile( child: ListTile(
leading: const Icon(Icons.check), leading: const Icon(Icons.check),
title: Text('notifyEmpty'.tr), title: Text('notifyEmpty'.tr),
@ -96,52 +59,149 @@ class _NotificationScreenState extends State<NotificationScreen> {
), ),
), ),
), ),
if (ws.notifications.isNotEmpty) if (nty.notifications
.where((x) => x.readAt == null)
.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10), padding: const EdgeInsets.symmetric(horizontal: 10),
color: Theme.of(context).colorScheme.secondaryContainer, color:
Theme.of(context).colorScheme.secondaryContainer,
child: ListTile( child: ListTile(
leading: const Icon(Icons.checklist), leading: const Icon(Icons.checklist),
title: Text('notifyAllRead'.tr), title: Text('notifyAllRead'.tr),
onTap: _isBusy ? null : () => _markAllRead(), onTap: nty.isBusy.value
? null
: () => nty.markAllRead(),
), ),
), ),
), ),
SliverList.separated( SliverList.separated(
itemCount: ws.notifications.length, itemCount: nty.notifications.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
var element = ws.notifications[index]; var element = nty.notifications[index];
return Dismissible( return ClipRect(
child: Dismissible(
direction: element.readAt == null
? DismissDirection.horizontal
: DismissDirection.none,
key: Key(const Uuid().v4()), key: Key(const Uuid().v4()),
background: Container( background: Container(
color: Colors.lightBlue, color: Colors.lightBlue,
padding: const EdgeInsets.symmetric(horizontal: 20), padding:
const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: const Icon(Icons.check, color: Colors.white), child:
const Icon(Icons.check, color: Colors.white),
), ),
child: ListTile( secondaryBackground: Container(
contentPadding: const EdgeInsets.symmetric( color: Colors.lightBlue,
horizontal: 24, padding:
vertical: 8, const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerRight,
child:
const Icon(Icons.check, color: Colors.white),
), ),
title: Text(element.title), child: Container(
subtitle: Column( padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 16,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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) if (element.subtitle != null)
Text(element.subtitle!), Text(
Text(element.body), 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: (_) => _markOneRead(element, index), ],
),
),
],
),
),
onDismissed: (_) => nty.markOneRead(element, index),
),
); );
}, },
separatorBuilder: (_, __) => separatorBuilder: (_, __) =>
const Divider(thickness: 0.3, height: 0.3), 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,9 +59,9 @@ 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,11 +74,9 @@ class _NotificationPreferencesScreenState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Column(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer, tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
@ -112,7 +110,6 @@ class _NotificationPreferencesScreenState
), ),
), ),
], ],
),
); );
} }
} }

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,7 +1,4 @@
import 'dart:io';
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';
@ -9,10 +6,12 @@ import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.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/providers/auth.dart'; 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});
@ -77,9 +76,12 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
XFile file;
final image = await _imagePicker.pickImage(source: ImageSource.gallery); final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return; if (image == null) return;
if (PlatformInfo.canCropImage) {
CroppedFile? croppedFile = await ImageCropper().cropImage( CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: image.path, sourcePath: image.path,
uiSettings: [ uiSettings: [
@ -106,7 +108,10 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
); );
if (croppedFile == null) return; if (croppedFile == null) return;
final file = File(croppedFile.path); file = XFile(croppedFile.path);
} else {
file = XFile(image.path);
}
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -181,15 +186,13 @@ 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,
child: ListView(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
const Gap(24), const Gap(24),
Stack( Stack(
children: [ children: [
AccountAvatar(content: _avatar, radius: 40), AttachedCircleAvatar(content: _avatar, radius: 40),
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 40, left: 40,
@ -338,7 +341,6 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
], ],
).paddingSymmetric(horizontal: padding), ).paddingSymmetric(horizontal: padding),
], ],
),
); );
} }

View File

@ -13,6 +13,7 @@ import 'package:solian/models/attachment.dart';
import 'package:solian/models/daily_sign.dart'; import 'package:solian/models/daily_sign.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/models/relations.dart';
import 'package:solian/models/subscription.dart'; import 'package:solian/models/subscription.dart';
import 'package:solian/providers/account_status.dart'; import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
@ -25,7 +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/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 {
@ -50,6 +52,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
Account? _userinfo; Account? _userinfo;
Subscription? _subscription; Subscription? _subscription;
Relationship? _relationship;
List<Post> _pinnedPosts = List.empty(); List<Post> _pinnedPosts = List.empty();
List<DailySignRecord> _dailySignRecords = List.empty(); List<DailySignRecord> _dailySignRecords = List.empty();
int _totalUpvote = 0, _totalDownvote = 0; int _totalUpvote = 0, _totalDownvote = 0;
@ -61,6 +64,15 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
setState(() => _isSubscribing = false); setState(() => _isSubscribing = false);
} }
Future<void> _getRelationship() async {
setState(() => _isBusy = true);
final relations = Get.find<RelationshipProvider>();
_relationship = await relations.getRelationship(_userinfo!.id);
setState(() => _isBusy = false);
}
Future<void> _getUserinfo() async { Future<void> _getUserinfo() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -120,6 +132,63 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
} }
} }
Future<void> _subscribeToUser() async {
setState(() => _isSubscribing = true);
_subscription =
await Get.find<SubscriptionProvider>().subscribeToUser(_userinfo!.id);
setState(() => _isSubscribing = false);
}
Future<void> _unsubscribeFromUser() async {
setState(() => _isSubscribing = true);
await Get.find<SubscriptionProvider>().unsubscribeFromUser(_userinfo!.id);
_subscription = null;
setState(() => _isSubscribing = false);
}
Future<void> _makeFriend() async {
setState(() => _isMakingFriend = true);
try {
_relationship = await _relationshipProvider.makeFriend(widget.name);
context.showSnackbar(
'accountFriendRequestSent'.tr,
);
} catch (e) {
context.showErrorDialog(e);
} finally {
setState(() => _isMakingFriend = false);
}
}
Future<void> _blockUser() async {
setState(() => _isMakingFriend = true);
try {
_relationship = await _relationshipProvider.blockUser(widget.name);
context.showSnackbar(
'accountBlocked'.tr,
);
} catch (e) {
context.showErrorDialog(e);
} finally {
setState(() => _isMakingFriend = false);
}
}
Future<void> _unblockUser() async {
setState(() => _isMakingFriend = true);
try {
_relationship =
await _relationshipProvider.editRelation(_userinfo!.id, 1);
context.showSnackbar(
'accountUnblocked'.tr,
);
} catch (e) {
context.showErrorDialog(e);
} finally {
setState(() => _isMakingFriend = false);
}
}
int get _userSocialCreditPoints { int get _userSocialCreditPoints {
return _totalUpvote * 2 - _totalDownvote + _postController.postTotal.value; return _totalUpvote * 2 - _totalDownvote + _postController.postTotal.value;
} }
@ -151,37 +220,24 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
}); });
_getUserinfo().then((_) { _getUserinfo().then((_) {
_getRelationship();
_getSubscription(); _getSubscription();
_getPinnedPosts(); _getPinnedPosts();
_getDailySignRecords(); _getDailySignRecords();
}); });
} }
Widget _buildStatisticsEntry(String label, String content) { @override
return Expanded( Widget build(BuildContext context) {
child: Column( if (_isBusy || _userinfo == null) {
children: [ return RootContainer(
Text( child: const Center(
label, child: CircularProgressIndicator(),
style: Theme.of(context).textTheme.bodySmall,
),
Text(
content,
style: Theme.of(context).textTheme.bodyLarge,
),
],
), ),
); );
} }
@override return RootContainer(
Widget build(BuildContext context) {
if (_isBusy || _userinfo == null) {
return const Center(child: CircularProgressIndicator());
}
return Material(
color: Theme.of(context).colorScheme.surface,
child: DefaultTabController( child: DefaultTabController(
length: 3, length: 3,
child: NestedScrollView( child: NestedScrollView(
@ -193,12 +249,19 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
toolbarHeight: AppTheme.toolbarHeight(context), toolbarHeight: AppTheme.toolbarHeight(context),
leadingWidth: 24, leadingWidth: 24,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
flexibleSpace: Row( flexibleSpace: SizedBox(
height: 56,
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(
@ -219,59 +282,31 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
), ),
), ),
if (_userinfo != null && _subscription == null) if (_userinfo != null && _subscription == null)
OutlinedButton( IconButton(
style: const ButtonStyle( style: const ButtonStyle(
visualDensity: visualDensity:
VisualDensity(horizontal: -4, vertical: -2), VisualDensity(horizontal: -4, vertical: -2),
), ),
onPressed: _isSubscribing onPressed: _isSubscribing ? null : _subscribeToUser,
? null icon: const Icon(Icons.add_circle_outline),
: () async { tooltip: 'subscribe'.tr,
setState(() => _isSubscribing = true);
_subscription =
await Get.find<SubscriptionProvider>()
.subscribeToUser(_userinfo!.id);
setState(() => _isSubscribing = false);
},
child: Text('subscribe'.tr),
) )
else if (_userinfo != null) else if (_userinfo != null)
OutlinedButton( IconButton(
style: const ButtonStyle( style: const ButtonStyle(
visualDensity: visualDensity:
VisualDensity(horizontal: -4, vertical: -2), VisualDensity(horizontal: -4, vertical: -2),
), ),
onPressed: _isSubscribing onPressed:
? null _isSubscribing ? null : _unsubscribeFromUser,
: () async { icon: const Icon(Icons.remove_circle_outline),
setState(() => _isSubscribing = true); tooltip: 'unsubscribe'.tr,
await Get.find<SubscriptionProvider>()
.unsubscribeFromUser(_userinfo!.id);
_subscription = null;
setState(() => _isSubscribing = false);
},
child: Text('unsubscribe'.tr),
), ),
if (_userinfo != null && if (_userinfo != null && _relationship == null)
!_relationshipProvider.hasFriend(_userinfo!))
IconButton( IconButton(
icon: const Icon(Icons.person_add), icon: const Icon(Icons.person_add),
onPressed: _isMakingFriend onPressed: _isMakingFriend ? null : _makeFriend,
? null tooltip: 'friendAdd'.tr,
: () async {
setState(() => _isMakingFriend = true);
try {
await _relationshipProvider
.makeFriend(widget.name);
context.showSnackbar(
'accountFriendRequestSent'.tr,
);
} catch (e) {
context.showErrorDialog(e);
} finally {
setState(() => _isMakingFriend = false);
}
},
) )
else else
const IconButton( const IconButton(
@ -283,6 +318,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
), ),
], ],
), ),
).paddingOnly(top: MediaQuery.of(context).padding.top),
bottom: TabBar( bottom: TabBar(
tabs: [ tabs: [
Tab(text: 'profilePage'.tr), Tab(text: 'profilePage'.tr),
@ -296,10 +332,11 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
body: TabBarView( body: TabBarView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: [ children: [
Column( ListView(
padding: const EdgeInsets.only(top: 16, bottom: 16),
children: [ children: [
const Gap(16), CenteredContainer(
AccountHeadingWidget( child: AccountHeadingWidget(
name: _userinfo!.name, name: _userinfo!.name,
nick: _userinfo!.nick, nick: _userinfo!.nick,
desc: _userinfo!.description, desc: _userinfo!.description,
@ -311,11 +348,12 @@ 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,
width: max(640, MediaQuery.of(context).size.width), width:
max(640, MediaQuery.of(context).size.width),
child: LineChart( child: LineChart(
LineChartData( LineChartData(
lineBarsData: [ lineBarsData: [
@ -415,9 +453,84 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
), ),
), ),
).marginOnly(right: 24, left: 12, bottom: 8, top: 24), ).marginOnly(
right: 24,
left: 12,
bottom: 8,
top: 24,
),
) )
], ],
appendWidgets: [
Card(
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
width: double.maxFinite,
child: Wrap(
alignment: WrapAlignment.spaceAround,
children: [
TextButton.icon(
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -2,
),
),
onPressed: () {
showDialog(
context: context,
builder: (context) => AbuseReportDialog(
resourceId: 'user:${_userinfo!.id}',
),
);
},
icon: const Icon(
Icons.flag,
size: 16,
),
label: Text('reportAbuse'.tr),
),
if (_relationship?.status != 2)
TextButton.icon(
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -2,
),
),
onPressed:
_isMakingFriend ? null : _blockUser,
icon: const Icon(
Icons.block,
size: 16,
),
label: Text('blockUser'.tr),
)
else
TextButton.icon(
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -2,
),
),
onPressed:
_isMakingFriend ? null : _unblockUser,
icon: const Icon(
Icons.add_circle_outline,
size: 16,
),
label: Text('unblockUser'.tr),
),
],
),
),
),
],
),
), ),
], ],
), ),
@ -433,7 +546,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_buildStatisticsEntry( _StatsWidget(
'totalSocialCreditPoints'.tr, 'totalSocialCreditPoints'.tr,
_userinfo != null _userinfo != null
? _userSocialCreditPoints.toString() ? _userSocialCreditPoints.toString()
@ -446,16 +559,16 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
Obx( Obx(
() => _buildStatisticsEntry( () => _StatsWidget(
'totalPostCount'.tr, 'totalPostCount'.tr,
_postController.postTotal.value.toString(), _postController.postTotal.value.toString(),
), ),
), ),
_buildStatisticsEntry( _StatsWidget(
'totalUpvote'.tr, 'totalUpvote'.tr,
_totalUpvote.toString(), _totalUpvote.toString(),
), ),
_buildStatisticsEntry( _StatsWidget(
'totalDownvote'.tr, 'totalDownvote'.tr,
_totalDownvote.toString(), _totalDownvote.toString(),
), ),
@ -475,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,
@ -496,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(),
@ -553,3 +664,28 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
); );
} }
} }
class _StatsWidget extends StatelessWidget {
final String label;
final String content;
const _StatsWidget(this.label, this.content);
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
Text(
content,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
}

View File

@ -7,10 +7,9 @@ import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; 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/channel.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';
@ -177,10 +176,9 @@ class _SignInScreenState extends State<SignInScreen> {
await auth.refreshAuthorizeStatus(); await auth.refreshAuthorizeStatus();
await auth.refreshUserProfile(); await auth.refreshUserProfile();
Get.find<ChannelProvider>().refreshAvailableChannel();
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();
@ -218,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,
child: CenteredContainer(
maxWidth: 360, maxWidth: 360,
child: Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: PageTransitionSwitcher( child: PageTransitionSwitcher(
transitionBuilder: ( transitionBuilder: (
Widget child, Widget child,

View File

@ -65,9 +65,7 @@ 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,
child: CenteredContainer(
maxWidth: 360, maxWidth: 360,
child: ListView( child: ListView(
shrinkWrap: true, shrinkWrap: true,
@ -96,8 +94,7 @@ class _SignUpScreenState extends State<SignUpScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: 'username'.tr, labelText: 'username'.tr,
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
TextField( TextField(
@ -110,8 +107,7 @@ class _SignUpScreenState extends State<SignUpScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: 'nickname'.tr, labelText: 'nickname'.tr,
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
TextField( TextField(
@ -124,8 +120,7 @@ class _SignUpScreenState extends State<SignUpScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: 'email'.tr, labelText: 'email'.tr,
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
TextField( TextField(
@ -139,8 +134,7 @@ class _SignUpScreenState extends State<SignUpScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: 'password'.tr, labelText: 'password'.tr,
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => _performAction(context), onSubmitted: (_) => _performAction(context),
), ),
const Gap(8), const Gap(8),
@ -206,7 +200,6 @@ class _SignUpScreenState extends State<SignUpScreen> {
), ),
) )
], ],
),
).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,11 +193,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
_chatController = ChatEventController(); _chatController = ChatEventController();
_chatController.initialize(); _chatController.initialize();
SharedPreferences.getInstance().then((inst) {
_prefs = inst;
_getOngoingCall(); _getOngoingCall();
_getChannel().then((_) { _getChannel().then((_) {
_chatController.getInitialEvents(_channel!, widget.realm); _chatController.getInitialEvents(_channel!, widget.realm);
_listenMessages(); _listenMessages();
}); });
});
} }
@override @override
@ -201,16 +208,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
String title = _channel?.name ?? 'loading'.tr; String title = _channel?.name ?? 'loading'.tr;
String? placeholder; String? placeholder;
if (_channel?.type == 1) {
final otherside = final otherside =
_channel!.members!.where((e) => e.account.id != _accountId).first; _channel?.members!.where((e) => e.account.id != _accountId).firstOrNull;
if (_channel?.type == 1 && otherside != null) {
title = otherside.account.nick; title = otherside.account.nick;
placeholder = 'messageInputPlaceholder'.trParams( placeholder = 'messageInputPlaceholder'.trParams(
{'channel': '@${otherside.account.name}'}, {'channel': '@${otherside.account.name}'},
); );
} }
return Scaffold( return ResponsiveRootContainer(
child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle(title), title: AppBarTitle(title),
@ -246,7 +255,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
.then((value) { .then((value) {
if (value == false) AppRouter.instance.pop(); if (value == false) AppRouter.instance.pop();
if (value != null) { if (value != null) {
final resp = Channel.fromJson(value as Map<String, dynamic>); final resp =
Channel.fromJson(value as Map<String, dynamic>);
_getChannel(alias: resp.alias); _getChannel(alias: resp.alias);
} }
}); });
@ -274,7 +284,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
channel: _channel!, channel: _channel!,
ongoingCall: _ongoingCall!, ongoingCall: _ongoingCall!,
onJoin: () { onJoin: () {
if (!AppTheme.isLargeScreen(context)) { if (!AppTheme.isUltraLargeScreen(context)) {
final ChatCallProvider call = Get.find(); final ChatCallProvider call = Get.find();
call.gotoScreen(context); call.gotoScreen(context);
} }
@ -282,6 +292,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
), ),
Expanded( Expanded(
child: ChatEventList( child: ChatEventList(
noAnimated:
_prefs.getBool('non_animated_message_list') ??
false,
scope: widget.realm, scope: widget.realm,
channel: _channel!, channel: _channel!,
chatController: _chatController, chatController: _chatController,
@ -328,7 +341,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
), ),
Obx(() { Obx(() {
final ChatCallProvider call = Get.find(); final ChatCallProvider call = Get.find();
if (call.isMounted.value && AppTheme.isLargeScreen(context)) { if (call.isMounted.value &&
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),
@ -346,6 +360,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
], ],
); );
}), }),
),
); );
} }

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,47 +1,166 @@
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:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/database/database.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';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart'; import 'package:solian/widgets/account/signin_required_overlay.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/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/sized_container.dart'; import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sidebar/empty_placeholder.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatelessWidget {
const ChatScreen({super.key}); const ChatScreen({super.key});
@override @override
State<ChatScreen> createState() => _ChatScreenState(); Widget build(BuildContext context) {
return const ResponsiveRootContainer(
child: ChatList(),
);
}
} }
class _ChatScreenState extends State<ChatScreen> { class ChatListShell extends StatelessWidget {
late final ChannelProvider _channels; final Widget? child;
const ChatListShell({super.key, required this.child});
@override
Widget build(BuildContext context) {
return RootContainer(
child: ResizableContainer(
direction: Axis.horizontal,
divider: ResizableDivider(
thickness: 0.3,
color: Theme.of(context).dividerColor.withOpacity(0.3),
),
children: [
const ResizableChild(
minSize: 280,
maxSize: 520,
size: ResizableSize.pixels(360),
child: ChatList(),
),
ResizableChild(
minSize: 280,
child: child ?? const EmptyPagePlaceholder(),
),
],
),
);
}
}
class ChatList extends StatefulWidget {
const ChatList({super.key});
@override
State<ChatList> createState() => _ChatListState();
}
class _ChatListState extends State<ChatList> {
List<Channel> _normalChannels = List.empty();
List<Channel> _directChannels = List.empty();
final Map<String, List<Channel>> _realmChannels = {};
late final ChannelProvider _channels = Get.find();
bool _isBusy = true;
List<Channel> _sortChannels(List<Channel> channels) {
channels.sort(
(a, b) =>
_lastMessages?[b.id]?.createdAt.compareTo(
_lastMessages?[a.id]?.createdAt ??
DateTime.fromMillisecondsSinceEpoch(0),
) ??
0,
);
return channels;
}
Future<void> _loadNormalChannels() async {
final resp = await _channels.listAvailableChannel(isDirect: false);
setState(() {
_normalChannels = _sortChannels(resp);
});
}
Future<void> _loadDirectChannels() async {
final resp = await _channels.listAvailableChannel(isDirect: true);
setState(() {
_directChannels = _sortChannels(resp);
});
}
Future<void> _loadRealmChannels(String realm) async {
final resp = await _channels.listAvailableChannel(scope: realm);
setState(() {
_realmChannels[realm] = _sortChannels(List.from(resp));
});
}
Future<void> _loadAllChannels() async {
final RealmProvider realms = Get.find();
Future.wait([
_loadNormalChannels(),
_loadDirectChannels(),
...realms.availableRealms.map((x) => _loadRealmChannels(x.alias)),
]);
}
Map<int, LocalMessageEventTableData>? _lastMessages;
Future<void> _loadLastMessages() async {
final ctrl = ChatEventController();
await ctrl.initialize();
final messages = await ctrl.src.getLastInAllChannels();
if (mounted) {
setState(() {
_lastMessages = messages
.map((k, v) => MapEntry(k, v.firstOrNull))
.cast<int, LocalMessageEventTableData>();
});
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
try { _loadLastMessages().then((_) {
_channels = Get.find(); if (!mounted) return;
_channels.refreshAvailableChannel(); _loadAllChannels().then((_) {
} catch (e) { if (mounted) {
context.showErrorDialog(e); setState(() => _isBusy = false);
} }
});
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final RealmProvider realms = Get.find();
return Material( return Obx(
color: Theme.of(context).colorScheme.surface, () => DefaultTabController(
length: 2 + realms.availableRealms.length,
child: ResponsiveRootContainer(
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
@ -58,13 +177,14 @@ class _ChatScreenState extends State<ChatScreen> {
child: ListTile( child: ListTile(
title: Text('channelOrganizeCommon'.tr), title: Text('channelOrganizeCommon'.tr),
leading: const Icon(Icons.tag), leading: const Icon(Icons.tag),
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding:
const EdgeInsets.symmetric(horizontal: 8),
), ),
onTap: () { onTap: () {
AppRouter.instance.pushNamed('channelOrganizing').then( AppRouter.instance.pushNamed('channelOrganizing').then(
(value) { (value) {
if (value != null) { if (value != null) {
_channels.refreshAvailableChannel(); _loadAllChannels();
} }
}, },
); );
@ -77,7 +197,8 @@ class _ChatScreenState extends State<ChatScreen> {
FontAwesomeIcons.userGroup, FontAwesomeIcons.userGroup,
size: 16, size: 16,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding:
const EdgeInsets.symmetric(horizontal: 8),
), ),
onTap: () { onTap: () {
final ChannelProvider channels = Get.find(); final ChannelProvider channels = Get.find();
@ -85,7 +206,7 @@ class _ChatScreenState extends State<ChatScreen> {
.createDirectChannel(context, 'global') .createDirectChannel(context, 'global')
.then((resp) { .then((resp) {
if (resp != null) { if (resp != null) {
_channels.refreshAvailableChannel(); _loadAllChannels();
} }
}).catchError((e) { }).catchError((e) {
context.showErrorDialog(e); context.showErrorDialog(e);
@ -98,11 +219,70 @@ class _ChatScreenState extends State<ChatScreen> {
width: AppTheme.isLargeScreen(context) ? 8 : 16, 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: [
AttachedCircleAvatar(
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: () => _channels.refreshAvailableChannel(), onDone: () => _loadAllChannels(),
); );
} }
@ -110,37 +290,50 @@ class _ChatScreenState extends State<ChatScreen> {
return Column( return Column(
children: [ children: [
Obx(() {
if (_channels.isLoading.isFalse) {
return const SizedBox.shrink();
} else {
return const LinearProgressIndicator();
}
}),
const ChatCallCurrentIndicator(), const ChatCallCurrentIndicator(),
LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: CenteredContainer( child: TabBarView(
child: RefreshIndicator( children: [
onRefresh: _channels.refreshAvailableChannel, RefreshIndicator(
child: Obx( onRefresh: _loadNormalChannels,
() => ChannelListWidget( child: ChannelListWidget(
noCategory: true, channels: _sortChannels([
channels: List.from([ ..._normalChannels,
..._channels.groupChannels ..._directChannels,
.where((x) => x.realmId == null), ..._realmChannels.values.expand((x) => x),
..._channels.directChannels
]), ]),
selfId: selfId, selfId: selfId,
useReplace: true, 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,11 +75,13 @@ 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;
if (mounted) {
setState(() { setState(() {
_currentMessages = out.$1; _currentMessages = out.$1;
_currentMessagesCount = out.$2; _currentMessagesCount = out.$2;
}); });
} }
}
bool _signingDaily = true; bool _signingDaily = true;
DailySignRecord? _signRecord; DailySignRecord? _signRecord;
@ -89,14 +91,18 @@ 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) {
if (mounted) {
setState(() => _signRecordHistory = value); setState(() => _signRecordHistory = value);
}
}); });
} catch (e) { } catch (e) {
context.showErrorDialog(e); context.showErrorDialog(e);
} }
if (mounted) {
setState(() => _signingDaily = false); setState(() => _signingDaily = false);
} }
}
Future<void> _signDaily() async { Future<void> _signDaily() async {
setState(() => _signingDaily = true); setState(() => _signingDaily = true);
@ -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),
], ],
), ),
); );

251
lib/screens/explore.dart Normal file
View File

@ -0,0 +1,251 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart';
import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/app_bar_leading.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/root_container.dart';
class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key});
@override
State<ExploreScreen> createState() => _ExploreScreenState();
}
class _ExploreScreenState extends State<ExploreScreen>
with SingleTickerProviderStateMixin {
late final PostListController _postController;
late final TabController _tabController;
List<StreamSubscription>? _subscriptions;
@override
void initState() {
super.initState();
final navState = Get.find<NavigationStateProvider>();
_postController = PostListController();
_postController.realm = navState.focusedRealm.value?.alias;
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
if (_postController.mode.value == _tabController.index) return;
_postController.mode.value = _tabController.index;
_postController.reloadAllOver();
});
_subscriptions = [
Get.find<NavigationStateProvider>().focusedRealm.listen((value) {
if (value?.alias != _postController.realm) {
_postController.realm = value?.alias;
_postController.reloadAllOver();
}
}),
];
}
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
return RootContainer(
child: Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
final value = await showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => const PostCreatePopup(),
);
if (value is Future) {
value.then((_) {
_postController.reloadAllOver();
});
} else if (value != null) {
_postController.reloadAllOver();
}
},
),
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [
SliverLayoutBuilder(
builder: (context, constraints) {
final scrollOffset = constraints.scrollOffset;
final colorChangeOffset = 120;
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,
),
],
);
},
)
];
},
body: Obx(() {
if (_postController.isPreparing.isTrue) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Column(
children: [
Expanded(
child: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: _tabController,
children: [
RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
ControlledPostListWidget(
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
)
: EdgeInsets.zero,
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]),
),
Obx(() {
if (auth.isAuthorized.value) {
return RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
ControlledPostListWidget(
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero,
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]),
);
} else {
return SigninRequiredOverlay(
onDone: () => _postController.reloadAllOver(),
);
}
}),
PostShuffleSwiper(controller: _postController),
],
),
),
],
);
}),
),
),
);
}
@override
void dispose() {
_postController.dispose();
if (_subscriptions != null) {
for (final subscription in _subscriptions!) {
subscription.cancel();
}
}
super.dispose();
}
}

View File

@ -1,283 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/posts/post_shuffle_swiper.dart';
import 'package:solian/widgets/posts/post_warped_list.dart';
class FeedScreen extends StatefulWidget {
const FeedScreen({super.key});
@override
State<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends State<FeedScreen>
with SingleTickerProviderStateMixin {
late final PostListController _postController;
late final TabController _tabController;
List<StreamSubscription>? _subscriptions;
@override
void initState() {
super.initState();
final navState = Get.find<NavigationStateProvider>();
_postController = PostListController();
_postController.realm = navState.focusedRealm.value?.alias;
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
if (_postController.mode.value == _tabController.index) return;
_postController.mode.value = _tabController.index;
_postController.reloadAllOver();
});
_subscriptions = [
Get.find<NavigationStateProvider>().focusedRealm.listen((value) {
if (value?.alias != _postController.realm) {
_postController.realm = value?.alias;
_postController.reloadAllOver();
}
}),
];
}
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
final NavigationStateProvider navState = Get.find();
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
final value = await showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => const PostCreatePopup(),
);
if (value is Future) {
value.then((_) {
_postController.reloadAllOver();
});
} else if (value != null) {
_postController.reloadAllOver();
}
},
),
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [
SliverAppBar(
title: AppBarTitle('feed'.tr),
centerTitle: false,
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,
tabs: [
Tab(text: 'postListNews'.tr),
Tab(text: 'postListFriends'.tr),
Tab(text: 'postListShuffle'.tr),
],
),
)
];
},
body: Obx(() {
if (_postController.isPreparing.isTrue) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Column(
children: [
if (navState.focusedRealm.value != null)
MaterialBanner(
leading: const Icon(Icons.layers),
content: Text(
'postBrowsingIn'.trParams({
'region': '#${navState.focusedRealm.value!.alias}',
}),
),
actions: const [SizedBox.shrink()],
),
Expanded(
child: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: _tabController,
children: [
RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
PostWarpedListWidget(
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]),
),
Obx(() {
if (auth.isAuthorized.value) {
return RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
PostWarpedListWidget(
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
]),
);
} else {
return SigninRequiredOverlay(
onDone: () => _postController.reloadAllOver(),
);
}
}),
PostShuffleSwiper(controller: _postController),
],
),
),
],
);
}),
),
),
);
}
@override
void dispose() {
_postController.dispose();
if (_subscriptions != null) {
for (final subscription in _subscriptions!) {
subscription.cancel();
}
}
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('feedSearchWithTag'.trParams({'key': widget.tag!})),
),
if (widget.category != null)
ListTile(
leading: const Icon(Icons.category),
tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('feedSearchWithCategory'
.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,37 +25,37 @@ 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,
child: FutureBuilder(
future: getDetail(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center( return const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
); );
@ -60,20 +63,48 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter(
child: LoadingIndicator(isActive: _isBusy),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostItem( child: PostItem(
item: item!, key: ValueKey(_item),
item: _item!,
isClickable: false, isClickable: false,
isOverrideEmbedClickable: true, isOverrideEmbedClickable: true,
isFullDate: true, isFullDate: true,
isFullContent: true,
isShowReply: false, isShowReply: false,
isContentSelectable: true, 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) {
if (value is Future) {
value.then((_) {
_getDetail();
});
} else if (value != null) {
_getDetail();
}
});
},
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: const Divider(thickness: 0.3, height: 1) child: const Divider(thickness: 0.3, height: 1).paddingOnly(
.paddingOnly(top: 4), top: 8,
),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Align( child: Align(
@ -84,14 +115,19 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
).paddingOnly(left: 24, right: 24, top: 16), ).paddingOnly(left: 24, right: 24, top: 16),
), ),
), ),
PostReplyList(item: item!), PostReplyList(
item: _item!,
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
)
: EdgeInsets.zero,
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.bottom), 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,117 +275,71 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
), ),
], ],
), ),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: Row( child: DefaultTabController(
length: 2,
child: AppTheme.isLargeScreen(context)
? Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Column( child: _PostEditorTextField(
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:
_editorController.contentController,
focusNode: _contentFocusNode, focusNode: _contentFocusNode,
decoration: InputDecoration.collapsed( controller: _editorController,
hintText: 'postContentPlaceholder'.tr, onUpdate: () => setState(() {}),
),
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 = [
_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: [
if (showFactors[0])
Text('postRestoreFromLocal'.tr,
style: textStyle)
.paddingOnly(right: 4),
if (showFactors[0])
InkWell(
child: Text('clear'.tr, style: textStyle),
onTap: () {
_editorController.localClear();
_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,
),
],
),
)
.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) const VerticalDivider(width: 0.3, thickness: 0.3)
.paddingSymmetric( .paddingSymmetric(horizontal: 16),
horizontal: 16,
),
if (AppTheme.isLargeScreen(context))
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding:
const EdgeInsets.only(top: 12, bottom: 64),
child: MarkdownTextContent( child: MarkdownTextContent(
content: _editorController.contentController.text, isAutoWarp: _editorController.mode.value == 0,
content:
_editorController.contentController.text,
parentId: 'post-editor-preview', parentId: 'post-editor-preview',
).paddingOnly(top: 12, right: 16), ).paddingOnly(right: 16),
), ),
), ),
], ],
)
: Column(
children: [
TabBar(
tabs: [
const Tab(icon: Icon(Icons.edit)),
const Tab(icon: Icon(Icons.preview)),
],
),
Expanded(
child: TabBarView(
children: [
_PostEditorTextField(
focusNode: _contentFocusNode,
controller: _editorController,
onUpdate: () => setState(() {}),
),
SingleChildScrollView(
padding: const EdgeInsets.only(
top: 12,
bottom: 64,
),
child: MarkdownTextContent(
isAutoWarp:
_editorController.mode.value == 0,
content: _editorController
.contentController.text,
parentId: 'post-editor-preview',
).paddingOnly(left: 16, right: 16),
)
],
),
),
],
),
), ),
), ),
Material( Material(
@ -390,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(
@ -519,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,
@ -546,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),
), ),
@ -577,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,7 +87,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
), ),
]; ];
return Column( return RootContainer(
child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@ -141,6 +143,7 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
), ),
), ),
], ],
),
); );
} }
} }

View File

@ -1,19 +1,19 @@
import 'dart:io';
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';
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/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart'; import 'package:solian/providers/content/attachment.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_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 {
@ -84,9 +84,12 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
XFile file;
final image = await _imagePicker.pickImage(source: ImageSource.gallery); final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return; if (image == null) return;
if (PlatformInfo.canCropImage) {
CroppedFile? croppedFile = await ImageCropper().cropImage( CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: image.path, sourcePath: image.path,
uiSettings: [ uiSettings: [
@ -113,7 +116,10 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
); );
if (croppedFile == null) return; if (croppedFile == null) return;
final file = File(croppedFile.path); file = XFile(croppedFile.path);
} else {
file = XFile(image.path);
}
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -184,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),
@ -203,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;
@ -68,12 +69,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
_channels.addAll( _channels.addAll(
resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(), resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(),
); );
_channels.addAll( _channels.addAll(availableResp);
availableResp.body
.map((e) => Channel.fromJson(e))
.toList()
.cast<Channel>(),
);
_channels.retainWhere((x) => channelIdx.add(x.id)); _channels.retainWhere((x) => channelIdx.add(x.id));
}); });
@ -91,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(
@ -260,7 +255,6 @@ class RealmChannelListWidget extends StatelessWidget {
child: ChannelListWidget( child: ChannelListWidget(
channels: channels, channels: channels,
selfId: auth.userProfile.value!['id'], selfId: auth.userProfile.value!['id'],
noCategory: true,
), ),
) )
], ],

View File

@ -1,15 +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: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 {
@ -21,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(
@ -31,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(
AppTheme.build(
Brightness.light,
seedColor: color,
), ),
AppTheme.build( SolianThemeData(
Brightness.dark, id: 'themeColorBlue',
seedColor: color, seedColor: const Color.fromRGBO(103, 96, 193, 1),
),
SolianThemeData(
id: 'themeColorMiku',
seedColor: const Color.fromRGBO(56, 120, 126, 1),
),
SolianThemeData(
id: 'themeColorKagamine',
seedColor: const Color.fromRGBO(244, 183, 63, 1),
),
SolianThemeData(
id: 'themeColorLuka',
seedColor: const Color.fromRGBO(243, 174, 218, 1),
), ),
);
_prefs?.setInt('global_theme_color', color.value);
context.clearSnackbar();
context.showSnackbar('themeColorApplied'.tr);
},
);
}
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) {
@ -74,19 +82,103 @@ 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,
child: ListView(
children: [ children: [
_buildCaptionHeader('themeColor'.tr), _buildCaptionHeader('theme'.tr),
SizedBox( ListTile(
height: 56, leading: const Icon(Icons.palette),
child: ListView( contentPadding: const EdgeInsets.symmetric(horizontal: 22),
scrollDirection: Axis.horizontal, title: Text('globalTheme'.tr),
children: _presentTheme trailing: DropdownButtonHideUnderline(
.map((x) => _buildThemeColorButton(x.$1, x.$2)) child: DropdownButton2<SolianThemeData>(
isExpanded: true,
hint: Text(
'theme'.tr,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).hintColor,
),
),
items: _presentTheme
.map((SolianThemeData item) =>
DropdownMenuItem<SolianThemeData>(
value: item,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
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(), .toList(),
).paddingSymmetric(horizontal: 12, vertical: 8), 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,
),
),
),
),
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), _buildCaptionHeader('notification'.tr),
Tooltip( Tooltip(
@ -106,8 +198,7 @@ class _SettingScreenState extends State<SettingScreen> {
) )
], ],
), ),
value: value: _prefs?.getBool('service_background_notification') ?? false,
_prefs?.getBool('service_background_notification') ?? false,
onChanged: (value) { onChanged: (value) {
_prefs _prefs
?.setBool('service_background_notification', value ?? false) ?.setBool('service_background_notification', value ?? false)
@ -125,9 +216,7 @@ class _SettingScreenState extends State<SettingScreen> {
subtitle: Text('updateCheckStrictlyDesc'.tr), subtitle: Text('updateCheckStrictlyDesc'.tr),
value: _prefs?.getBool('check_update_strictly') ?? false, value: _prefs?.getBool('check_update_strictly') ?? false,
onChanged: (value) { onChanged: (value) {
_prefs _prefs?.setBool('check_update_strictly', value ?? false).then((_) {
?.setBool('check_update_strictly', value ?? false)
.then((_) {
setState(() {}); setState(() {});
}); });
}, },
@ -180,6 +269,21 @@ class _SettingScreenState extends State<SettingScreen> {
], ],
); );
}), }),
_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), _buildCaptionHeader('more'.tr),
ListTile( ListTile(
leading: const Icon(Icons.delete_sweep), leading: const Icon(Icons.delete_sweep),
@ -205,6 +309,21 @@ class _SettingScreenState extends State<SettingScreen> {
}); });
}, },
), ),
if (PlatformInfo.canRateTheApp)
ListTile(
leading: const Icon(Icons.star),
trailing: const Icon(Icons.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
title: Text('rateTheApp'.tr),
subtitle: Text('rateTheAppDesc'.tr),
onTap: () {
final inAppReview = InAppReview.instance;
inAppReview.openStoreListing(
appStoreId: '6499032345',
);
},
),
ListTile( ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.info_outline),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
@ -215,7 +334,6 @@ class _SettingScreenState extends State<SettingScreen> {
}, },
), ),
], ],
),
); );
} }
} }

View File

@ -2,7 +2,9 @@ import 'package:firebase_analytics/firebase_analytics.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/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/navigation/app_navigation_drawer.dart'; import 'package:solian/widgets/navigation/app_navigation.dart';
import 'package:solian/widgets/navigation/app_navigation_bottom.dart';
import 'package:solian/widgets/navigation/app_navigation_rail.dart';
final GlobalKey<ScaffoldState> rootScaffoldKey = GlobalKey<ScaffoldState>(); final GlobalKey<ScaffoldState> rootScaffoldKey = GlobalKey<ScaffoldState>();
@ -39,17 +41,41 @@ class RootShell extends StatelessWidget {
); );
} }
final showRailNavigation = AppTheme.isLargeScreen(context);
final destNames = [
'postDetail',
...AppNavigation.destinations.map((x) => x.page),
];
final showBottomNavigation =
destNames.contains(routeName) && !showRailNavigation;
return Scaffold( return Scaffold(
key: rootScaffoldKey, key: rootScaffoldKey,
drawer: AppTheme.isLargeScreen(context) backgroundColor: Theme.of(context).colorScheme.surface,
? null bottomNavigationBar: showBottomNavigation
: AppNavigationDrawer(routeName: routeName), ? AppNavigationBottom(
initialIndex: AppNavigation.destinations
.map((x) => x.page)
.toList()
.indexOf(routeName ?? 'page'),
)
: null,
body: AppTheme.isLargeScreen(context) body: AppTheme.isLargeScreen(context)
? Row( ? Row(
children: [ children: [
if (showNavigation) AppNavigationDrawer(routeName: routeName), if (showRailNavigation)
if (showNavigation) AppNavigationRail(
const VerticalDivider(thickness: 0.3, width: 1), initialIndex: AppNavigation.destinations
.map((x) => x.page)
.toList()
.indexOf(routeName ?? 'page'),
),
if (showRailNavigation)
const VerticalDivider(
width: 0.3,
thickness: 0.3,
),
Expanded(child: child), Expanded(child: child),
], ],
) )

View File

@ -1,62 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.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/sidebar/sidebar_placeholder.dart';
class SidebarShell extends StatelessWidget {
final bool showAppBar;
final GoRouterState state;
final Widget child;
final bool sidebarFirst;
final Widget? sidebar;
const SidebarShell({
super.key,
required this.child,
required this.state,
this.showAppBar = true,
this.sidebarFirst = false,
this.sidebar,
});
List<Widget> buildContent(BuildContext context) {
return [
Flexible(
flex: 2,
child: child,
),
if (AppTheme.isExtraLargeScreen(context))
const VerticalDivider(thickness: 0.3, width: 1),
if (AppTheme.isExtraLargeScreen(context))
Flexible(
flex: 1,
child: sidebar ?? const SidebarPlaceholder(),
),
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: showAppBar
? AppBar(
leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle(state.topRoute?.name?.tr ?? 'page'.tr),
centerTitle: false,
toolbarHeight: AppTheme.toolbarHeight(context),
)
: null,
body: AppTheme.isLargeScreen(context)
? Row(
children: sidebarFirst
? buildContent(context).reversed.toList()
: buildContent(context),
)
: child,
);
}
}

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 {
@ -6,7 +7,10 @@ abstract class AppTheme {
MediaQuery.of(context).size.width > 640; MediaQuery.of(context).size.width > 640;
static bool isExtraLargeScreen(BuildContext context) => static bool isExtraLargeScreen(BuildContext context) =>
MediaQuery.of(context).size.width > 720; MediaQuery.of(context).size.width > 920;
static bool isUltraLargeScreen(BuildContext context) =>
MediaQuery.of(context).size.width > 1200;
static bool isSpecializedMacOS(BuildContext context) => static bool isSpecializedMacOS(BuildContext context) =>
PlatformInfo.isMacOS && !AppTheme.isLargeScreen(context); PlatformInfo.isMacOS && !AppTheme.isLargeScreen(context);
@ -38,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',
@ -52,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,19 +1,22 @@
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;
const AccountAvatar({ const AttachedCircleAvatar({
super.key, super.key,
required this.content, required this.content,
this.bgColor, this.bgColor,
this.feColor, this.feColor,
this.radius, this.radius,
this.fallbackWidget,
}); });
@override @override
@ -35,12 +38,61 @@ class AccountAvatar extends StatelessWidget {
backgroundColor: bgColor, backgroundColor: bgColor,
backgroundImage: !isEmpty ? AutoCacheImage.provider(url) : null, backgroundImage: !isEmpty ? AutoCacheImage.provider(url) : null,
child: isEmpty child: isEmpty
? Icon( ? (fallbackWidget ??
Icon(
Icons.image,
size: radius != null ? radius! * 1.2 : 24,
color: feColor,
))
: null,
);
}
}
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, Icons.account_circle,
size: radius != null ? radius! * 1.2 : 24, size: radius != null ? radius! * 1.2 : 24,
color: feColor, color: feColor,
) )),
: null, ),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
name: username,
),
);
},
); );
} }
} }

View File

@ -23,6 +23,7 @@ class AccountHeadingWidget extends StatelessWidget {
final AccountProfile? profile; final AccountProfile? profile;
final List<AccountBadge>? badges; final List<AccountBadge>? badges;
final List<Widget>? extraWidgets; final List<Widget>? extraWidgets;
final List<Widget>? appendWidgets;
final Future<Response>? status; final Future<Response>? status;
final Function? onEditStatus; final Function? onEditStatus;
@ -39,6 +40,7 @@ class AccountHeadingWidget extends StatelessWidget {
this.profile, this.profile,
this.status, this.status,
this.extraWidgets, this.extraWidgets,
this.appendWidgets,
this.onEditStatus, this.onEditStatus,
}); });
@ -82,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),
), ),
], ],
), ),
@ -257,6 +259,7 @@ class AccountHeadingWidget extends StatelessWidget {
), ),
), ),
).paddingSymmetric(horizontal: 16), ).paddingSymmetric(horizontal: 16),
...?appendWidgets?.map((x) => x.paddingSymmetric(horizontal: 16)),
], ],
), ),
); );

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,
@ -106,15 +105,19 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
extraWidgets: [ extraWidgets: [
Card( Card(
child: ListTile( child: ListTile(
leading: const Icon(
Icons.contact_page_outlined,
),
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
title: Text('visitProfilePage'.tr), title: Text('visitProfilePage'.tr),
subtitle: Text('learnMoreAboutPerson'.tr),
visualDensity: visualDensity:
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,18 +23,14 @@ 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,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Theme backgroundColor: Theme.of(context).colorScheme.surface,
.of(context)
.colorScheme
.surface,
context: context, context: context,
builder: (context) => builder: (context) => AccountProfilePopup(
AccountProfilePopup(
name: element.related.name, name: element.related.name,
), ),
); );
@ -49,9 +45,13 @@ class SilverRelativeList extends StatelessWidget {
onPressed: () { onPressed: () {
final RelationshipProvider provider = Get.find(); final RelationshipProvider provider = Get.find();
if (element.status == 0) { if (element.status == 0) {
provider.handleRelation(element, true).then((_) => onUpdate()); provider
.handleRelation(element, true)
.then((_) => onUpdate());
} else { } else {
provider.editRelation(element, 1).then((_) => onUpdate()); provider
.editRelation(element.relatedId, 1)
.then((_) => onUpdate());
} }
}, },
), ),
@ -61,9 +61,13 @@ class SilverRelativeList extends StatelessWidget {
onPressed: () { onPressed: () {
final RelationshipProvider provider = Get.find(); final RelationshipProvider provider = Get.find();
if (element.status == 0) { if (element.status == 0) {
provider.handleRelation(element, false).then((_) => onUpdate()); provider
.handleRelation(element, false)
.then((_) => onUpdate());
} else { } else {
provider.editRelation(element, 2).then((_) => onUpdate()); provider
.editRelation(element.relatedId, 2)
.then((_) => onUpdate());
} }
}, },
), ),

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 DrawerButton(
onPressed: () => rootScaffoldKey.currentState!.openDrawer(),
);
} else {
return const SizedBox.shrink(); 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;
}); });
@ -396,7 +402,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
), ),
if (!element.isCompleted && if (!element.isCompleted &&
element.error == null && element.error == null &&
canBeCrop) canBeCrop &&
PlatformInfo.canCropImage)
Obx( Obx(
() => IconButton( () => IconButton(
color: Colors.teal, color: Colors.teal,
@ -552,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);
},
),
], ],
), ),
], ],
@ -659,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: [
@ -744,8 +767,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
return IgnorePointer( return IgnorePointer(
ignoring: _uploadController.isUploading.value, ignoring: _uploadController.isUploading.value,
child: Container( child: Container(
height: 64,
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
top: BorderSide( top: BorderSide(
@ -754,11 +777,9 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
), ),
), ),
), ),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 0, runSpacing: 8,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center, runAlignment: WrapAlignment.center,
children: [ children: [
@ -766,55 +787,62 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
PlatformInfo.isIOS || PlatformInfo.isIOS ||
PlatformInfo.isWeb) && PlatformInfo.isWeb) &&
!widget.imageOnly) !widget.imageOnly)
ElevatedButton.icon( IconButton(
icon: const Icon(Icons.paste), icon: const Icon(Icons.paste),
label: Text('attachmentAddClipboard'.tr), tooltip: 'attachmentAddClipboard'.tr,
style: const ButtonStyle(visualDensity: density), style: const ButtonStyle(visualDensity: density),
color: Theme.of(context).colorScheme.primary,
onPressed: () => _pasteFileToUpload(), onPressed: () => _pasteFileToUpload(),
), ),
ElevatedButton.icon( IconButton(
icon: const Icon(Icons.add_photo_alternate), icon: const Icon(Icons.add_photo_alternate),
label: Text('attachmentAddGalleryPhoto'.tr), tooltip: 'attachmentAddGalleryPhoto'.tr,
style: const ButtonStyle(visualDensity: density), style: const ButtonStyle(visualDensity: density),
color: Theme.of(context).colorScheme.primary,
onPressed: () => _pickPhotoToUpload(), onPressed: () => _pickPhotoToUpload(),
), ),
if (!widget.imageOnly) if (!widget.imageOnly)
ElevatedButton.icon( IconButton(
icon: const Icon(Icons.add_road), icon: const Icon(Icons.add_road),
label: Text('attachmentAddGalleryVideo'.tr), tooltip: 'attachmentAddGalleryVideo'.tr,
style: const ButtonStyle(visualDensity: density), style: const ButtonStyle(visualDensity: density),
color: Theme.of(context).colorScheme.primary,
onPressed: () => _pickVideoToUpload(), onPressed: () => _pickVideoToUpload(),
), ),
ElevatedButton.icon( if (PlatformInfo.isMobile)
IconButton(
icon: const Icon(Icons.photo_camera_back), icon: const Icon(Icons.photo_camera_back),
label: Text('attachmentAddCameraPhoto'.tr), tooltip: 'attachmentAddCameraPhoto'.tr,
style: const ButtonStyle(visualDensity: density), style: const ButtonStyle(visualDensity: density),
color: Theme.of(context).colorScheme.primary,
onPressed: () => _takeMediaToUpload(false), onPressed: () => _takeMediaToUpload(false),
), ),
if (!widget.imageOnly) if (!widget.imageOnly && PlatformInfo.isMobile)
ElevatedButton.icon( IconButton(
icon: const Icon(Icons.video_camera_back_outlined), icon: const Icon(Icons.video_camera_back_outlined),
label: Text('attachmentAddCameraVideo'.tr), tooltip: 'attachmentAddCameraVideo'.tr,
style: const ButtonStyle(visualDensity: density), style: const ButtonStyle(visualDensity: density),
color: Theme.of(context).colorScheme.primary,
onPressed: () => _takeMediaToUpload(true), onPressed: () => _takeMediaToUpload(true),
), ),
if (!widget.imageOnly) if (!widget.imageOnly)
ElevatedButton.icon( IconButton(
icon: const Icon(Icons.file_present_rounded), icon: const Icon(Icons.file_present_rounded),
label: Text('attachmentAddFile'.tr), tooltip: 'attachmentAddFile'.tr,
style: const ButtonStyle(visualDensity: density), style: const ButtonStyle(visualDensity: density),
color: Theme.of(context).colorScheme.primary,
onPressed: () => _pickFileToUpload(), onPressed: () => _pickFileToUpload(),
), ),
if (!widget.imageOnly) if (!widget.imageOnly)
ElevatedButton.icon( IconButton(
icon: const Icon(Icons.link), icon: const Icon(Icons.link),
label: Text('attachmentAddFile'.tr), tooltip: 'attachmentAddLink'.tr,
style: const ButtonStyle(visualDensity: density), style: const ButtonStyle(visualDensity: density),
color: Theme.of(context).colorScheme.primary,
onPressed: () => _linkAttachments(), onPressed: () => _linkAttachments(),
), ),
], ],
).paddingSymmetric(horizontal: 12), ).paddingSymmetric(horizontal: 12),
),
) )
.animate( .animate(
target: _uploadController.isUploading.value ? 0 : 1, target: _uploadController.isUploading.value ? 0 : 1,

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,23 +241,56 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
child: Wrap( child: Wrap(
spacing: 6, spacing: 6,
children: [ children: [
if (widget.item.metadata?['exif'] == null)
Text( Text(
'#${widget.item.rid}', '#${widget.item.rid}',
style: metaTextStyle, style: metaTextStyle,
), ),
if (widget.item.metadata?['width'] != null && if (widget.item.metadata?['exif']?['Model'] != null)
widget.item.metadata?['height'] != 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( Text(
'${widget.item.metadata?['width']}x${widget.item.metadata?['height']}', '${widget.item.metadata?['width']}x${widget.item.metadata?['height']}',
style: metaTextStyle, 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)
style: metaTextStyle, .toStringAsFixed(2),
),
Text(
widget.item.size.formatBytes(),
style: metaTextStyle, style: metaTextStyle,
), ),
Text( Text(

View File

@ -21,6 +21,7 @@ class AttachmentItem extends StatefulWidget {
final bool showBadge; final bool showBadge;
final bool showHideButton; final bool showHideButton;
final bool autoload; final bool autoload;
final bool isDense;
final BoxFit fit; final BoxFit fit;
final String? badge; final String? badge;
final Function? onHide; final Function? onHide;
@ -34,6 +35,7 @@ class AttachmentItem extends StatefulWidget {
this.showBadge = true, this.showBadge = true,
this.showHideButton = true, this.showHideButton = true,
this.autoload = false, this.autoload = false,
this.isDense = false,
this.onHide, this.onHide,
}); });
@ -53,6 +55,7 @@ class _AttachmentItemState extends State<AttachmentItem> {
fit: widget.fit, fit: widget.fit,
showBadge: widget.showBadge, showBadge: widget.showBadge,
showHideButton: widget.showHideButton, showHideButton: widget.showHideButton,
isDense: widget.isDense,
onHide: widget.onHide, onHide: widget.onHide,
); );
case 'video': case 'video':
@ -120,6 +123,7 @@ class _AttachmentItemImage extends StatelessWidget {
final bool showBadge; final bool showBadge;
final bool showHideButton; final bool showHideButton;
final BoxFit fit; final BoxFit fit;
final bool isDense;
final String? badge; final String? badge;
final Function? onHide; final Function? onHide;
@ -128,6 +132,7 @@ class _AttachmentItemImage extends StatelessWidget {
required this.item, required this.item,
required this.showBadge, required this.showBadge,
required this.showHideButton, required this.showHideButton,
required this.isDense,
required this.fit, required this.fit,
this.badge, this.badge,
this.onHide, this.onHide,
@ -146,14 +151,22 @@ class _AttachmentItemImage extends StatelessWidget {
'/attachments/${item.rid}', '/attachments/${item.rid}',
), ),
fit: fit, fit: fit,
isDense: isDense,
), ),
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,15 +133,34 @@ class _AttachmentListState extends State<AttachmentList> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
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(); _getMetadataList();
} }
} else {
setState(() {
_attachments = widget.attachments!;
_isLoading = false;
});
_calculateAspectRatio();
}
}
Color get _unFocusColor => Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75); Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
@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,27 +296,37 @@ class _AttachmentListState extends State<AttachmentList> {
); );
} }
final isNotPureImage = _attachmentsMeta.any(
(x) => x?.mimetype.split('/').firstOrNull != 'image',
);
if (widget.isGrid && (widget.isForceGrid || !isNotPureImage)) {
const radius = BorderRadius.all(Radius.circular(8));
return GridView.builder(
padding: EdgeInsets.zero,
primary: false,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: math.min(3, widget.attachmentsId.length),
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
),
itemCount: widget.attachmentsId.length,
itemBuilder: (context, idx) {
final element = _attachmentsMeta[idx];
return Container( return Container(
constraints: BoxConstraints(
maxHeight: 320,
),
child: ListView.separated(
padding: widget.padding,
scrollDirection: Axis.horizontal,
shrinkWrap: true,
itemCount: _attachments.length,
itemBuilder: (context, 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(
constraints: BoxConstraints(
maxWidth: math.min(
widget.columnMaxWidth,
MediaQuery.of(context).size.width -
(widget.padding?.horizontal ?? 0),
),
),
child: AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh, 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,
@ -236,37 +337,11 @@ class _AttachmentListState extends State<AttachmentList> {
borderRadius: radius, borderRadius: radius,
child: _buildEntry(element, idx), 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(
@ -338,12 +414,14 @@ class AttachmentListEntry extends StatelessWidget {
badge: showBadge ? badgeContent : null, badge: showBadge ? badgeContent : null,
showHideButton: !item!.isMature || showMature, showHideButton: !item!.isMature || showMature,
autoload: autoload, autoload: autoload,
isDense: isDense,
onHide: () { onHide: () {
onReveal(false); onReveal(false);
}, },
), ),
if (item!.isMature && !showMature) if (item!.isMature && !showMature)
BackdropFilter( ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -351,6 +429,7 @@ class AttachmentListEntry extends StatelessWidget {
), ),
), ),
), ),
),
if (item!.isMature && !showMature) if (item!.isMature && !showMature)
CenteredContainer( CenteredContainer(
maxWidth: 280, maxWidth: 280,

View File

@ -34,8 +34,17 @@ class AutoCacheImage extends StatelessWidget {
progressIndicatorBuilder: noProgressIndicator progressIndicatorBuilder: noProgressIndicator
? null ? null
: (context, url, downloadProgress) => Center( : (context, url, downloadProgress) => Center(
child: CircularProgressIndicator( child: TweenAnimationBuilder(
value: downloadProgress.progress, tween: Tween(
begin: 0,
end: downloadProgress.progress ?? 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: downloadProgress.progress != null
? value.toDouble()
: null,
),
), ),
), ),
errorWidget: noErrorWidget errorWidget: noErrorWidget
@ -74,12 +83,21 @@ class AutoCacheImage extends StatelessWidget {
ImageChunkEvent? loadingProgress) { ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child; if (loadingProgress == null) return child;
return Center( return Center(
child: CircularProgressIndicator( child: TweenAnimationBuilder(
value: loadingProgress.expectedTotalBytes != null tween: Tween(
begin: 0,
end: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / ? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes! loadingProgress.expectedTotalBytes!
: 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? value.toDouble()
: null, : null,
), ),
),
); );
}, },
errorBuilder: noErrorWidget errorBuilder: noErrorWidget

View File

@ -98,12 +98,12 @@ class ChannelCallIndicator extends StatelessWidget {
child: Text('callJoin'.tr), child: Text('callJoin'.tr),
); );
} else if (call.channel.value?.id == channel.id && } else if (call.channel.value?.id == channel.id &&
!AppTheme.isLargeScreen(context)) { !AppTheme.isUltraLargeScreen(context)) {
return TextButton( return TextButton(
onPressed: () => onJoin(), onPressed: () => onJoin(),
child: Text('callResume'.tr), child: Text('callResume'.tr),
); );
} else if (!AppTheme.isLargeScreen(context)) { } else if (!AppTheme.isUltraLargeScreen(context)) {
return TextButton( return TextButton(
onPressed: null, onPressed: null,
child: Text('callJoin'.tr), child: Text('callJoin'.tr),

View File

@ -4,18 +4,18 @@ import 'package:flutter/material.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';
import 'package:intl/intl.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';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/database/database.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:badges/badges.dart' as badges;
class ChannelListWidget extends StatefulWidget { class ChannelListWidget extends StatefulWidget {
final List<Channel> channels; final List<Channel> channels;
final int selfId; final int selfId;
final bool isDense;
final bool isCollapsed;
final bool noCategory;
final bool useReplace; final bool useReplace;
final Function(Channel)? onSelected; final Function(Channel)? onSelected;
@ -23,9 +23,6 @@ class ChannelListWidget extends StatefulWidget {
super.key, super.key,
required this.channels, required this.channels,
required this.selfId, required this.selfId,
this.isDense = false,
this.isCollapsed = false,
this.noCategory = false,
this.useReplace = false, this.useReplace = false,
this.onSelected, this.onSelected,
}); });
@ -35,43 +32,27 @@ class ChannelListWidget extends StatefulWidget {
} }
class _ChannelListWidgetState extends State<ChannelListWidget> { class _ChannelListWidgetState extends State<ChannelListWidget> {
final List<Channel> _globalChannels = List.empty(growable: true); Map<int, LocalMessageEventTableData>? _lastMessages;
final Map<String, List<Channel>> _inRealms = {};
final ChatEventController _eventController = ChatEventController(); final ChatEventController _eventController = ChatEventController();
void _mapChannels() { Future<void> _loadLastMessages() async {
_inRealms.clear(); final messages = await _eventController.src.getLastInAllChannels();
_globalChannels.clear(); if (mounted) {
setState(() {
if (widget.noCategory) { _lastMessages = messages
_globalChannels.addAll(widget.channels); .map((k, v) => MapEntry(k, v.firstOrNull))
return; .cast<int, LocalMessageEventTableData>();
});
} }
for (final channel in widget.channels) {
if (channel.realmId != null) {
if (_inRealms[channel.realm!.alias] == null) {
_inRealms[channel.realm!.alias] = List.empty(growable: true);
}
_inRealms[channel.realm!.alias]!.add(channel);
} else {
_globalChannels.add(channel);
}
}
}
@override
void didUpdateWidget(covariant ChannelListWidget oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() => _mapChannels());
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_mapChannels(); _eventController.initialize().then((_) {
_eventController.initialize(); _loadLastMessages();
});
} }
void _gotoChannel(Channel item) { void _gotoChannel(Channel item) {
@ -98,107 +79,183 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
} }
} }
Widget _buildDirectMessageDescription(Channel item, ChannelMember otherside) { Widget _buildTitle(Channel item, ChannelMember? otherside) {
if (PlatformInfo.isWeb) { if (otherside != null) {
return Text('channelDirectDescription'.trParams( return Row(
{'username': '@${otherside.account.name}'}, crossAxisAlignment: CrossAxisAlignment.center,
)); children: [
} Expanded(child: Text(otherside.account.nick)),
if (_lastMessages != null && _lastMessages![item.id] != null)
return FutureBuilder( Text(
future: Future.delayed( DateFormat('MM/dd').format(
const Duration(milliseconds: 500), _lastMessages![item.id]!.createdAt.toLocal(),
() => _eventController.src.getLastInChannel(item),
), ),
builder: (context, snapshot) { style: TextStyle(
if (!snapshot.hasData && snapshot.data == null) { fontSize: 12,
return Text('channelDirectDescription'.trParams( color:
{'username': '@${otherside.account.name}'}, Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
)); ),
),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: Text(item.name)),
if (_lastMessages != null && _lastMessages![item.id] != null)
Text(
DateFormat('MM/dd').format(
_lastMessages![item.id]!.createdAt.toLocal(),
),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
),
),
],
);
} }
final data = snapshot.data!.data!; Widget _buildSubtitle(Channel item, ChannelMember? otherside) {
return Text( if (PlatformInfo.isWeb) {
'${data.sender.account.nick}: ${data.body['text'] ?? 'Unsupported message to preview'}', return otherside != null
? Text(
'channelDirectDescription'.trParams(
{'username': '@${otherside.account.name}'},
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
)
: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
return AnimatedSwitcher(
switchInCurve: Curves.easeIn,
switchOutCurve: Curves.easeOut,
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
duration: const Duration(milliseconds: 300),
child: (_lastMessages == null || _lastMessages![item.id] == null)
? Builder(
builder: (context) {
return otherside != null
? Text(
'channelDirectDescription'.trParams(
{'username': '@${otherside.account.name}'},
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
)
: Builder(
builder: (context) {
final data = _lastMessages![item.id]!.data!;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (item.type == 0)
Badge(
label: Text(data.sender.account.nick),
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
textColor:
Theme.of(context).colorScheme.onSecondaryContainer,
),
if (item.type == 0) const Gap(6),
if (data.body['text'] != null)
Expanded(
child: Text(
data.body['text'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
else
Badge(label: Text('unablePreview'.tr)),
],
);
},
),
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.centerLeft,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
); );
}, },
); );
} }
Widget _buildEntry(Channel item) { Widget _buildEntry(Channel item) {
final padding = widget.isDense const padding = EdgeInsets.symmetric(horizontal: 20);
? const EdgeInsets.symmetric(horizontal: 20)
: const EdgeInsets.symmetric(horizontal: 16);
if (item.type == 1) {
final otherside = final otherside =
item.members!.where((e) => e.account.id != widget.selfId).first; item.members!.where((e) => e.account.id != widget.selfId).firstOrNull;
final avatar = AccountAvatar( if (item.type == 1 && otherside != null) {
final avatar = AttachedCircleAvatar(
content: otherside.account.avatar, content: otherside.account.avatar,
radius: widget.isDense ? 12 : 20, radius: 20,
bgColor: Theme.of(context).colorScheme.primary, bgColor: Theme.of(context).colorScheme.primary,
feColor: Theme.of(context).colorScheme.onPrimary, feColor: Theme.of(context).colorScheme.onPrimary,
); );
if (widget.isCollapsed) {
return Tooltip(
message: otherside.account.nick,
child: InkWell(
child: avatar.paddingSymmetric(vertical: 12),
onTap: () => _gotoChannel(item),
),
);
}
return ListTile( return ListTile(
leading: avatar, leading: avatar,
contentPadding: padding, contentPadding: padding,
title: Text(otherside.account.nick), title: _buildTitle(item, otherside),
subtitle: !widget.isDense subtitle: _buildSubtitle(item, otherside),
? _buildDirectMessageDescription(item, otherside)
: null,
onTap: () => _gotoChannel(item), onTap: () => _gotoChannel(item),
); );
} else { } else {
final avatar = CircleAvatar( final avatar = CircleAvatar(
backgroundColor: item.realmId == null backgroundColor: Theme.of(context).colorScheme.primary,
? Theme.of(context).colorScheme.primary radius: 20,
: Colors.transparent,
radius: widget.isDense ? 12 : 20,
child: FaIcon( child: FaIcon(
FontAwesomeIcons.hashtag, FontAwesomeIcons.hashtag,
color: item.realmId == null color: Theme.of(context).colorScheme.onPrimary,
? Theme.of(context).colorScheme.onPrimary size: 16,
: Theme.of(context).colorScheme.primary,
size: widget.isDense ? 12 : 16,
), ),
); );
if (widget.isCollapsed) {
return Tooltip(
message: item.name,
child: InkWell(
child: avatar.paddingSymmetric(vertical: 12),
onTap: () => _gotoChannel(item),
),
);
}
return ListTile( return ListTile(
minTileHeight: widget.isDense ? 48 : null, minTileHeight: null,
leading: avatar, leading: item.realmId == null
? avatar
: badges.Badge(
position: badges.BadgePosition.bottomEnd(bottom: -4, end: -6),
badgeStyle: badges.BadgeStyle(
badgeColor: Theme.of(context).colorScheme.secondaryContainer,
padding: const EdgeInsets.all(2),
elevation: 8,
),
badgeContent: AttachedCircleAvatar(
content: item.realm?.avatar,
radius: 10,
fallbackWidget: const Icon(
Icons.workspaces,
size: 16,
),
),
child: avatar,
),
contentPadding: padding, contentPadding: padding,
title: Text(item.name), title: _buildTitle(item, null),
subtitle: !widget.isDense subtitle: _buildSubtitle(item, null),
? Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
onTap: () => _gotoChannel(item), onTap: () => _gotoChannel(item),
); );
} }
@ -206,13 +263,12 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.noCategory) {
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
SliverList.builder( SliverList.builder(
itemCount: _globalChannels.length, itemCount: widget.channels.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final element = _globalChannels[index]; final element = widget.channels[index];
return _buildEntry(element); return _buildEntry(element);
}, },
), ),
@ -220,36 +276,4 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
], ],
); );
} }
return CustomScrollView(
slivers: [
SliverList.builder(
itemCount: _globalChannels.length,
itemBuilder: (context, index) {
final element = _globalChannels[index];
return _buildEntry(element);
},
),
SliverList.list(
children: _inRealms.entries.map((element) {
return ExpansionTile(
tilePadding: const EdgeInsets.only(left: 20, right: 24),
minTileHeight: 48,
title: Text(element.value.first.realm!.name),
leading: CircleAvatar(
backgroundColor: Colors.teal,
radius: widget.isDense ? 12 : 24,
child: Icon(
Icons.workspaces,
color: Colors.white,
size: widget.isDense ? 12 : 16,
),
),
children: element.value.map((x) => _buildEntry(x)).toList(),
);
}).toList(),
),
],
);
}
} }

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,9 +84,11 @@ class ChatEventList extends StatelessWidget {
final item = chatController.currentEvents[index].data; final item = chatController.currentEvents[index].data;
return GestureDetector( return TapRegion(
child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: ChatEvent( child: Builder(builder: (context) {
final widget = ChatEvent(
key: Key('m${item!.uuid}'), key: Key('m${item!.uuid}'),
item: item, item: item,
isMerged: isMerged, isMerged: isMerged,
@ -71,23 +96,33 @@ class ChatEventList extends StatelessWidget {
).paddingOnly( ).paddingOnly(
top: !isMerged ? 8 : 0, top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0, bottom: !hasMerged ? 8 : 0,
),
onLongPress: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ChatEventAction(
channel: channel,
realm: channel.realm,
item: item,
onEdit: () {
onEdit(item);
},
onReply: () {
onReply(item);
},
),
); );
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

@ -49,6 +49,7 @@ class ChatEventMessage extends StatelessWidget {
return MarkdownTextContent( return MarkdownTextContent(
parentId: 'm${item.id}', parentId: 'm${item.id}',
isSelectable: true, isSelectable: true,
isAutoWarp: true,
content: body.text, content: body.text,
); );
} }

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

@ -42,9 +42,10 @@ class DailySignHistoryChartDialog extends StatelessWidget {
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
) )
: Column( : SizedBox(
mainAxisSize: MainAxisSize.min, width: double.maxFinite,
crossAxisAlignment: CrossAxisAlignment.start, child: ListView(
shrinkWrap: true,
children: [ children: [
Text( Text(
'dailySignHistoryRecent'.tr, 'dailySignHistoryRecent'.tr,
@ -204,13 +205,15 @@ class DailySignHistoryChartDialog extends StatelessWidget {
.map((spot) => LineTooltipItem( .map((spot) => LineTooltipItem(
'+${spot.y.toStringAsFixed(0)} EXP\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}', '+${spot.y.toStringAsFixed(0)} EXP\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
TextStyle( TextStyle(
color: color: Theme.of(context)
Theme.of(context).colorScheme.onSurface, .colorScheme
.onSurface,
), ),
)) ))
.toList(), .toList(),
getTooltipColor: (_) => getTooltipColor: (_) => Theme.of(context)
Theme.of(context).colorScheme.surfaceContainerHigh, .colorScheme
.surfaceContainerHigh,
)), )),
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: const AxisTitles( topTitles: const AxisTitles(
@ -255,6 +258,7 @@ class DailySignHistoryChartDialog extends StatelessWidget {
).marginOnly(right: 24, bottom: 8, top: 8), ).marginOnly(right: 24, bottom: 8, top: 8),
], ],
), ),
),
); );
} }
} }

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();
return Wrap( if (matches.isEmpty) return;
children: matches.map((x) {
return Container( List<LinkMeta> out = List.empty(growable: true);
constraints: BoxConstraints( for (final x in matches) {
maxWidth: matches.length == 1 ? 480 : 340, final result = await expandController.expandLink(x.group(0)!);
), if (result != null) out.add(result);
child: FutureBuilder(
future: expandController.expandLink(x.group(0)!),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
} }
setState(() => _meta = out);
}
@override
void initState() {
super.initState();
_doExpand();
}
@override
Widget build(BuildContext context) {
if (_meta?.isEmpty ?? true) return const SizedBox.shrink();
return Wrap(
children: _meta!.map((x) {
return Container(
constraints: BoxConstraints(
maxWidth: _meta!.length == 1 ? 480 : 340,
),
child: Builder(
builder: (context) {
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,43 +1,84 @@
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: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;
const MarkdownTextContent({ const MarkdownTextContent({
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,
}); });
@override
State<MarkdownTextContent> createState() => _MarkdownTextContentState();
}
class _MarkdownTextContentState extends State<MarkdownTextContent> {
final List<int> _stickerSizes = [];
@override
initState() {
super.initState();
final stickerRegex = RegExp(r':([-\w]+):');
// Split the content into paragraphs
final paragraphs = widget.content.split(RegExp(r'\n\s*\n'));
// Iterate over each paragraph to process stickers individually
for (var idx = 0; idx < paragraphs.length; idx++) {
// Getting paragraph
var paragraph = paragraphs[idx];
// Matching stickers
final stickerMatch = stickerRegex.allMatches(paragraph);
if (stickerMatch.length > 3) {
_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));
}
}
}
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
final emojiRegex = RegExp(r':([-\w]+):'); var stickerIdx = 0;
final emojiMatch = emojiRegex.allMatches(content);
final isOnlyEmoji = content.replaceAll(emojiRegex, '').trim().isEmpty;
return Markdown( return Markdown(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
data: content, data: widget.content,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
styleSheet: MarkdownStyleSheet.fromTheme( styleSheet: MarkdownStyleSheet.fromTheme(
Theme.of(context), Theme.of(context),
).copyWith( ).copyWith(
textScaleFactor: isLargeText ? 1.1 : 1, textScaler: TextScaler.linear(widget.isLargeText ? 1.1 : 1),
blockquote: TextStyle( blockquote: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
@ -53,15 +94,32 @@ class MarkdownTextContent extends StatelessWidget {
), ),
), ),
), ),
codeblockDecoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 0.3,
), ),
borderRadius: const BorderRadius.all(Radius.circular(4)),
color: Theme.of(context).colorScheme.surface.withOpacity(0.5),
)),
builders: {
'code': _MarkdownTextCodeElement(),
},
softLineBreak: true,
extensionSet: markdown.ExtensionSet( extensionSet: markdown.ExtensionSet(
markdown.ExtensionSet.gitHubFlavored.blockSyntaxes, <markdown.BlockSyntax>[
markdown.CodeBlockSyntax(),
...markdown.ExtensionSet.commonMark.blockSyntaxes,
...markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
],
<markdown.InlineSyntax>[ <markdown.InlineSyntax>[
if (widget.isAutoWarp) markdown.LineBreakSyntax(),
_UserNameCardInlineSyntax(), _UserNameCardInlineSyntax(),
_CustomEmoteInlineSyntax(), _CustomEmoteInlineSyntax(),
markdown.EmojiSyntax(),
markdown.AutolinkSyntax(), markdown.AutolinkSyntax(),
markdown.AutolinkExtensionSyntax(), markdown.AutolinkExtensionSyntax(),
markdown.CodeSyntax(),
...markdown.ExtensionSet.commonMark.inlineSyntaxes,
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes ...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
], ],
), ),
@ -97,23 +155,24 @@ class MarkdownTextContent extends StatelessWidget {
final segments = url.replaceFirst('solink://', '').split('/'); final segments = url.replaceFirst('solink://', '').split('/');
switch (segments[0]) { switch (segments[0]) {
case 'stickers': case 'stickers':
double radius = 8; double radius = 4;
final StickerProvider sticker = Get.find(); final StickerProvider sticker = Get.find();
if (emojiMatch.length <= 1 && isOnlyEmoji) {
width = 128; // Adjust sticker size based on the sticker count in this paragraph
height = 128; width =
} else if (emojiMatch.length <= 3 && isOnlyEmoji) { _stickerSizes.elementAtOrNull(stickerIdx)?.toDouble() ?? 16;
width = 32; height =
height = 32; _stickerSizes.elementAtOrNull(stickerIdx)?.toDouble() ?? 16;
} else { if (width > 16) {
radius = 4; radius = 8;
width = 16;
height = 16;
} }
stickerIdx++;
fit = BoxFit.contain; fit = BoxFit.contain;
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(radius)), borderRadius: BorderRadius.all(Radius.circular(radius)),
child: Container( child: Container(
width: width,
height: height,
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
child: FutureBuilder( child: FutureBuilder(
future: sticker.getStickerByAlias(segments[1]), future: sticker.getStickerByAlias(segments[1]),
@ -133,21 +192,44 @@ class MarkdownTextContent extends StatelessWidget {
), ),
).paddingSymmetric(vertical: 4); ).paddingSymmetric(vertical: 4);
case 'attachments': case 'attachments':
final match = widget.attachments
?.where((x) => x.rid == segments[1])
.firstOrNull;
const radius = BorderRadius.all(Radius.circular(8)); const radius = BorderRadius.all(Radius.circular(8));
if (match != null) {
final isImage =
match.mimetype.split('/').firstOrNull == 'image';
double ratio = match.metadata?['ratio']?.toDouble() ??
(isImage ? 1 : 16 / 9);
return LimitedBox(
maxWidth: 480,
maxHeight: 640,
child: AspectRatio(
aspectRatio: ratio,
child: ClipRRect(
borderRadius: radius,
child: AttachmentItem(
parentId: widget.parentId,
item: match,
),
),
),
).paddingSymmetric(vertical: 4);
} else {
return LimitedBox( return LimitedBox(
maxHeight: MediaQuery.of(context).size.width, maxHeight: MediaQuery.of(context).size.width,
child: ClipRRect( child: ClipRRect(
borderRadius: radius, borderRadius: radius,
child: AttachmentSelfContainedEntry( child: AttachmentSelfContainedEntry(
isDense: true, isDense: true,
parentId: parentId, parentId: widget.parentId,
rid: segments[1], rid: segments[1],
), ),
), ),
).paddingSymmetric(vertical: 4); ).paddingSymmetric(vertical: 4);
} }
} }
}
return AutoCacheImage( return AutoCacheImage(
url, url,
width: width, width: width,
@ -160,14 +242,14 @@ class MarkdownTextContent extends StatelessWidget {
@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
@ -183,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
@ -203,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

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/account_status.dart';
import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/relation.dart';
import 'package:badges/badges.dart' as badges;
import 'package:solian/widgets/account/account_avatar.dart';
class AppAccountWidget extends StatefulWidget {
const AppAccountWidget({super.key});
@override
State<AppAccountWidget> createState() => _AppAccountWidgetState();
}
class _AppAccountWidgetState extends State<AppAccountWidget> {
AccountStatus? _accountStatus;
Future<void> _getStatus() async {
final StatusProvider provider = Get.find();
final resp = await provider.getCurrentStatus();
final status = AccountStatus.fromJson(resp.body);
if (mounted) {
setState(() {
_accountStatus = status;
});
}
}
@override
void initState() {
super.initState();
_getStatus();
}
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
return Obx(() {
if (auth.isAuthorized.isFalse || auth.userProfile.value == null) {
return const Icon(Icons.account_circle);
}
final statusBadgeColor = _accountStatus != null
? StatusProvider.determineStatus(_accountStatus!).$2
: Colors.grey;
final RelationshipProvider relations = Get.find();
final accountNotifications = relations.friendRequestCount.value;
return badges.Badge(
badgeContent: Text(
accountNotifications.toString(),
style: const TextStyle(color: Colors.white),
),
showBadge: accountNotifications > 0,
position: badges.BadgePosition.topEnd(
top: -10,
end: -6,
),
child: badges.Badge(
showBadge: _accountStatus != null,
badgeStyle: badges.BadgeStyle(badgeColor: statusBadgeColor),
position: badges.BadgePosition.bottomEnd(
bottom: 0,
end: -2,
),
child: AttachedCircleAvatar(
radius: 14,
content: auth.userProfile.value!['avatar'],
),
),
);
});
}
}

View File

@ -1,27 +1,33 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/utils.dart'; import 'package:get/utils.dart';
import 'package:solian/widgets/navigation/app_account_widget.dart';
abstract class AppNavigation { abstract class AppNavigation {
static List<AppNavigationDestination> destinations = [ static List<AppNavigationDestination> destinations = [
AppNavigationDestination( AppNavigationDestination(
icon: Icons.dashboard, icon: const Icon(Icons.dashboard),
label: 'dashboard'.tr, label: 'dashboardNav'.tr,
page: 'dashboard', page: 'dashboard',
), ),
AppNavigationDestination( AppNavigationDestination(
icon: Icons.newspaper, icon: const Icon(Icons.explore),
label: 'feed'.tr, label: 'explore'.tr,
page: 'feed', page: 'explore',
), ),
AppNavigationDestination( AppNavigationDestination(
icon: Icons.workspaces, icon: const Icon(Icons.forum),
label: 'chat'.tr,
page: 'chat',
),
AppNavigationDestination(
icon: const Icon(Icons.workspaces),
label: 'realms'.tr, label: 'realms'.tr,
page: 'realms', page: 'realms',
), ),
AppNavigationDestination( AppNavigationDestination(
icon: Icons.forum, icon: const AppAccountWidget(),
label: 'chat'.tr, label: 'accountNav'.tr,
page: 'chat', page: 'account',
), ),
]; ];
@ -30,7 +36,7 @@ abstract class AppNavigation {
} }
class AppNavigationDestination { class AppNavigationDestination {
final IconData icon; final Widget icon;
final String label; final String label;
final String page; final String page;

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