> allOffsets,
84 | ) {
85 | final object = Category(
86 | createdAt: reader.readDateTime(offsets[0]),
87 | id: id,
88 | name: reader.readString(offsets[1]),
89 | updatedAt: reader.readDateTime(offsets[2]),
90 | );
91 | return object;
92 | }
93 |
94 | P _categoryDeserializeProp(
95 | IsarReader reader,
96 | int propertyId,
97 | int offset,
98 | Map> allOffsets,
99 | ) {
100 | switch (propertyId) {
101 | case 0:
102 | return (reader.readDateTime(offset)) as P;
103 | case 1:
104 | return (reader.readString(offset)) as P;
105 | case 2:
106 | return (reader.readDateTime(offset)) as P;
107 | default:
108 | throw IsarError('Unknown property with id $propertyId');
109 | }
110 | }
111 |
112 | Id _categoryGetId(Category object) {
113 | return object.id ?? Isar.autoIncrement;
114 | }
115 |
116 | List> _categoryGetLinks(Category object) {
117 | return [object.feeds];
118 | }
119 |
120 | void _categoryAttach(IsarCollection col, Id id, Category object) {
121 | object.id = id;
122 | object.feeds.attach(col, col.isar.collection(), r'feeds', id);
123 | }
124 |
125 | extension CategoryQueryWhereSort on QueryBuilder {
126 | QueryBuilder anyId() {
127 | return QueryBuilder.apply(this, (query) {
128 | return query.addWhereClause(const IdWhereClause.any());
129 | });
130 | }
131 | }
132 |
133 | extension CategoryQueryWhere on QueryBuilder {
134 | QueryBuilder idEqualTo(Id id) {
135 | return QueryBuilder.apply(this, (query) {
136 | return query.addWhereClause(IdWhereClause.between(
137 | lower: id,
138 | upper: id,
139 | ));
140 | });
141 | }
142 |
143 | QueryBuilder idNotEqualTo(Id id) {
144 | return QueryBuilder.apply(this, (query) {
145 | if (query.whereSort == Sort.asc) {
146 | return query
147 | .addWhereClause(
148 | IdWhereClause.lessThan(upper: id, includeUpper: false),
149 | )
150 | .addWhereClause(
151 | IdWhereClause.greaterThan(lower: id, includeLower: false),
152 | );
153 | } else {
154 | return query
155 | .addWhereClause(
156 | IdWhereClause.greaterThan(lower: id, includeLower: false),
157 | )
158 | .addWhereClause(
159 | IdWhereClause.lessThan(upper: id, includeUpper: false),
160 | );
161 | }
162 | });
163 | }
164 |
165 | QueryBuilder idGreaterThan(Id id,
166 | {bool include = false}) {
167 | return QueryBuilder.apply(this, (query) {
168 | return query.addWhereClause(
169 | IdWhereClause.greaterThan(lower: id, includeLower: include),
170 | );
171 | });
172 | }
173 |
174 | QueryBuilder idLessThan(Id id,
175 | {bool include = false}) {
176 | return QueryBuilder.apply(this, (query) {
177 | return query.addWhereClause(
178 | IdWhereClause.lessThan(upper: id, includeUpper: include),
179 | );
180 | });
181 | }
182 |
183 | QueryBuilder idBetween(
184 | Id lowerId,
185 | Id upperId, {
186 | bool includeLower = true,
187 | bool includeUpper = true,
188 | }) {
189 | return QueryBuilder.apply(this, (query) {
190 | return query.addWhereClause(IdWhereClause.between(
191 | lower: lowerId,
192 | includeLower: includeLower,
193 | upper: upperId,
194 | includeUpper: includeUpper,
195 | ));
196 | });
197 | }
198 | }
199 |
200 | extension CategoryQueryFilter
201 | on QueryBuilder {
202 | QueryBuilder createdAtEqualTo(
203 | DateTime value) {
204 | return QueryBuilder.apply(this, (query) {
205 | return query.addFilterCondition(FilterCondition.equalTo(
206 | property: r'createdAt',
207 | value: value,
208 | ));
209 | });
210 | }
211 |
212 | QueryBuilder createdAtGreaterThan(
213 | DateTime value, {
214 | bool include = false,
215 | }) {
216 | return QueryBuilder.apply(this, (query) {
217 | return query.addFilterCondition(FilterCondition.greaterThan(
218 | include: include,
219 | property: r'createdAt',
220 | value: value,
221 | ));
222 | });
223 | }
224 |
225 | QueryBuilder createdAtLessThan(
226 | DateTime value, {
227 | bool include = false,
228 | }) {
229 | return QueryBuilder.apply(this, (query) {
230 | return query.addFilterCondition(FilterCondition.lessThan(
231 | include: include,
232 | property: r'createdAt',
233 | value: value,
234 | ));
235 | });
236 | }
237 |
238 | QueryBuilder createdAtBetween(
239 | DateTime lower,
240 | DateTime upper, {
241 | bool includeLower = true,
242 | bool includeUpper = true,
243 | }) {
244 | return QueryBuilder.apply(this, (query) {
245 | return query.addFilterCondition(FilterCondition.between(
246 | property: r'createdAt',
247 | lower: lower,
248 | includeLower: includeLower,
249 | upper: upper,
250 | includeUpper: includeUpper,
251 | ));
252 | });
253 | }
254 |
255 | QueryBuilder idIsNull() {
256 | return QueryBuilder.apply(this, (query) {
257 | return query.addFilterCondition(const FilterCondition.isNull(
258 | property: r'id',
259 | ));
260 | });
261 | }
262 |
263 | QueryBuilder idIsNotNull() {
264 | return QueryBuilder.apply(this, (query) {
265 | return query.addFilterCondition(const FilterCondition.isNotNull(
266 | property: r'id',
267 | ));
268 | });
269 | }
270 |
271 | QueryBuilder idEqualTo(Id? value) {
272 | return QueryBuilder.apply(this, (query) {
273 | return query.addFilterCondition(FilterCondition.equalTo(
274 | property: r'id',
275 | value: value,
276 | ));
277 | });
278 | }
279 |
280 | QueryBuilder idGreaterThan(
281 | Id? value, {
282 | bool include = false,
283 | }) {
284 | return QueryBuilder.apply(this, (query) {
285 | return query.addFilterCondition(FilterCondition.greaterThan(
286 | include: include,
287 | property: r'id',
288 | value: value,
289 | ));
290 | });
291 | }
292 |
293 | QueryBuilder idLessThan(
294 | Id? value, {
295 | bool include = false,
296 | }) {
297 | return QueryBuilder.apply(this, (query) {
298 | return query.addFilterCondition(FilterCondition.lessThan(
299 | include: include,
300 | property: r'id',
301 | value: value,
302 | ));
303 | });
304 | }
305 |
306 | QueryBuilder idBetween(
307 | Id? lower,
308 | Id? upper, {
309 | bool includeLower = true,
310 | bool includeUpper = true,
311 | }) {
312 | return QueryBuilder.apply(this, (query) {
313 | return query.addFilterCondition(FilterCondition.between(
314 | property: r'id',
315 | lower: lower,
316 | includeLower: includeLower,
317 | upper: upper,
318 | includeUpper: includeUpper,
319 | ));
320 | });
321 | }
322 |
323 | QueryBuilder nameEqualTo(
324 | String value, {
325 | bool caseSensitive = true,
326 | }) {
327 | return QueryBuilder.apply(this, (query) {
328 | return query.addFilterCondition(FilterCondition.equalTo(
329 | property: r'name',
330 | value: value,
331 | caseSensitive: caseSensitive,
332 | ));
333 | });
334 | }
335 |
336 | QueryBuilder nameGreaterThan(
337 | String value, {
338 | bool include = false,
339 | bool caseSensitive = true,
340 | }) {
341 | return QueryBuilder.apply(this, (query) {
342 | return query.addFilterCondition(FilterCondition.greaterThan(
343 | include: include,
344 | property: r'name',
345 | value: value,
346 | caseSensitive: caseSensitive,
347 | ));
348 | });
349 | }
350 |
351 | QueryBuilder nameLessThan(
352 | String value, {
353 | bool include = false,
354 | bool caseSensitive = true,
355 | }) {
356 | return QueryBuilder.apply(this, (query) {
357 | return query.addFilterCondition(FilterCondition.lessThan(
358 | include: include,
359 | property: r'name',
360 | value: value,
361 | caseSensitive: caseSensitive,
362 | ));
363 | });
364 | }
365 |
366 | QueryBuilder nameBetween(
367 | String lower,
368 | String upper, {
369 | bool includeLower = true,
370 | bool includeUpper = true,
371 | bool caseSensitive = true,
372 | }) {
373 | return QueryBuilder.apply(this, (query) {
374 | return query.addFilterCondition(FilterCondition.between(
375 | property: r'name',
376 | lower: lower,
377 | includeLower: includeLower,
378 | upper: upper,
379 | includeUpper: includeUpper,
380 | caseSensitive: caseSensitive,
381 | ));
382 | });
383 | }
384 |
385 | QueryBuilder nameStartsWith(
386 | String value, {
387 | bool caseSensitive = true,
388 | }) {
389 | return QueryBuilder.apply(this, (query) {
390 | return query.addFilterCondition(FilterCondition.startsWith(
391 | property: r'name',
392 | value: value,
393 | caseSensitive: caseSensitive,
394 | ));
395 | });
396 | }
397 |
398 | QueryBuilder nameEndsWith(
399 | String value, {
400 | bool caseSensitive = true,
401 | }) {
402 | return QueryBuilder.apply(this, (query) {
403 | return query.addFilterCondition(FilterCondition.endsWith(
404 | property: r'name',
405 | value: value,
406 | caseSensitive: caseSensitive,
407 | ));
408 | });
409 | }
410 |
411 | QueryBuilder nameContains(
412 | String value,
413 | {bool caseSensitive = true}) {
414 | return QueryBuilder.apply(this, (query) {
415 | return query.addFilterCondition(FilterCondition.contains(
416 | property: r'name',
417 | value: value,
418 | caseSensitive: caseSensitive,
419 | ));
420 | });
421 | }
422 |
423 | QueryBuilder nameMatches(
424 | String pattern,
425 | {bool caseSensitive = true}) {
426 | return QueryBuilder.apply(this, (query) {
427 | return query.addFilterCondition(FilterCondition.matches(
428 | property: r'name',
429 | wildcard: pattern,
430 | caseSensitive: caseSensitive,
431 | ));
432 | });
433 | }
434 |
435 | QueryBuilder nameIsEmpty() {
436 | return QueryBuilder.apply(this, (query) {
437 | return query.addFilterCondition(FilterCondition.equalTo(
438 | property: r'name',
439 | value: '',
440 | ));
441 | });
442 | }
443 |
444 | QueryBuilder nameIsNotEmpty() {
445 | return QueryBuilder.apply(this, (query) {
446 | return query.addFilterCondition(FilterCondition.greaterThan(
447 | property: r'name',
448 | value: '',
449 | ));
450 | });
451 | }
452 |
453 | QueryBuilder updatedAtEqualTo(
454 | DateTime value) {
455 | return QueryBuilder.apply(this, (query) {
456 | return query.addFilterCondition(FilterCondition.equalTo(
457 | property: r'updatedAt',
458 | value: value,
459 | ));
460 | });
461 | }
462 |
463 | QueryBuilder updatedAtGreaterThan(
464 | DateTime value, {
465 | bool include = false,
466 | }) {
467 | return QueryBuilder.apply(this, (query) {
468 | return query.addFilterCondition(FilterCondition.greaterThan(
469 | include: include,
470 | property: r'updatedAt',
471 | value: value,
472 | ));
473 | });
474 | }
475 |
476 | QueryBuilder updatedAtLessThan(
477 | DateTime value, {
478 | bool include = false,
479 | }) {
480 | return QueryBuilder.apply(this, (query) {
481 | return query.addFilterCondition(FilterCondition.lessThan(
482 | include: include,
483 | property: r'updatedAt',
484 | value: value,
485 | ));
486 | });
487 | }
488 |
489 | QueryBuilder updatedAtBetween(
490 | DateTime lower,
491 | DateTime upper, {
492 | bool includeLower = true,
493 | bool includeUpper = true,
494 | }) {
495 | return QueryBuilder.apply(this, (query) {
496 | return query.addFilterCondition(FilterCondition.between(
497 | property: r'updatedAt',
498 | lower: lower,
499 | includeLower: includeLower,
500 | upper: upper,
501 | includeUpper: includeUpper,
502 | ));
503 | });
504 | }
505 | }
506 |
507 | extension CategoryQueryObject
508 | on QueryBuilder {}
509 |
510 | extension CategoryQueryLinks
511 | on QueryBuilder {
512 | QueryBuilder feeds(
513 | FilterQuery q) {
514 | return QueryBuilder.apply(this, (query) {
515 | return query.link(q, r'feeds');
516 | });
517 | }
518 |
519 | QueryBuilder feedsLengthEqualTo(
520 | int length) {
521 | return QueryBuilder.apply(this, (query) {
522 | return query.linkLength(r'feeds', length, true, length, true);
523 | });
524 | }
525 |
526 | QueryBuilder feedsIsEmpty() {
527 | return QueryBuilder.apply(this, (query) {
528 | return query.linkLength(r'feeds', 0, true, 0, true);
529 | });
530 | }
531 |
532 | QueryBuilder feedsIsNotEmpty() {
533 | return QueryBuilder.apply(this, (query) {
534 | return query.linkLength(r'feeds', 0, false, 999999, true);
535 | });
536 | }
537 |
538 | QueryBuilder feedsLengthLessThan(
539 | int length, {
540 | bool include = false,
541 | }) {
542 | return QueryBuilder.apply(this, (query) {
543 | return query.linkLength(r'feeds', 0, true, length, include);
544 | });
545 | }
546 |
547 | QueryBuilder
548 | feedsLengthGreaterThan(
549 | int length, {
550 | bool include = false,
551 | }) {
552 | return QueryBuilder.apply(this, (query) {
553 | return query.linkLength(r'feeds', length, include, 999999, true);
554 | });
555 | }
556 |
557 | QueryBuilder feedsLengthBetween(
558 | int lower,
559 | int upper, {
560 | bool includeLower = true,
561 | bool includeUpper = true,
562 | }) {
563 | return QueryBuilder.apply(this, (query) {
564 | return query.linkLength(
565 | r'feeds', lower, includeLower, upper, includeUpper);
566 | });
567 | }
568 | }
569 |
570 | extension CategoryQuerySortBy on QueryBuilder {
571 | QueryBuilder sortByCreatedAt() {
572 | return QueryBuilder.apply(this, (query) {
573 | return query.addSortBy(r'createdAt', Sort.asc);
574 | });
575 | }
576 |
577 | QueryBuilder sortByCreatedAtDesc() {
578 | return QueryBuilder.apply(this, (query) {
579 | return query.addSortBy(r'createdAt', Sort.desc);
580 | });
581 | }
582 |
583 | QueryBuilder sortByName() {
584 | return QueryBuilder.apply(this, (query) {
585 | return query.addSortBy(r'name', Sort.asc);
586 | });
587 | }
588 |
589 | QueryBuilder sortByNameDesc() {
590 | return QueryBuilder.apply(this, (query) {
591 | return query.addSortBy(r'name', Sort.desc);
592 | });
593 | }
594 |
595 | QueryBuilder sortByUpdatedAt() {
596 | return QueryBuilder.apply(this, (query) {
597 | return query.addSortBy(r'updatedAt', Sort.asc);
598 | });
599 | }
600 |
601 | QueryBuilder sortByUpdatedAtDesc() {
602 | return QueryBuilder.apply(this, (query) {
603 | return query.addSortBy(r'updatedAt', Sort.desc);
604 | });
605 | }
606 | }
607 |
608 | extension CategoryQuerySortThenBy
609 | on QueryBuilder {
610 | QueryBuilder thenByCreatedAt() {
611 | return QueryBuilder.apply(this, (query) {
612 | return query.addSortBy(r'createdAt', Sort.asc);
613 | });
614 | }
615 |
616 | QueryBuilder thenByCreatedAtDesc() {
617 | return QueryBuilder.apply(this, (query) {
618 | return query.addSortBy(r'createdAt', Sort.desc);
619 | });
620 | }
621 |
622 | QueryBuilder thenById() {
623 | return QueryBuilder.apply(this, (query) {
624 | return query.addSortBy(r'id', Sort.asc);
625 | });
626 | }
627 |
628 | QueryBuilder thenByIdDesc() {
629 | return QueryBuilder.apply(this, (query) {
630 | return query.addSortBy(r'id', Sort.desc);
631 | });
632 | }
633 |
634 | QueryBuilder thenByName() {
635 | return QueryBuilder.apply(this, (query) {
636 | return query.addSortBy(r'name', Sort.asc);
637 | });
638 | }
639 |
640 | QueryBuilder thenByNameDesc() {
641 | return QueryBuilder.apply(this, (query) {
642 | return query.addSortBy(r'name', Sort.desc);
643 | });
644 | }
645 |
646 | QueryBuilder thenByUpdatedAt() {
647 | return QueryBuilder.apply(this, (query) {
648 | return query.addSortBy(r'updatedAt', Sort.asc);
649 | });
650 | }
651 |
652 | QueryBuilder thenByUpdatedAtDesc() {
653 | return QueryBuilder.apply(this, (query) {
654 | return query.addSortBy(r'updatedAt', Sort.desc);
655 | });
656 | }
657 | }
658 |
659 | extension CategoryQueryWhereDistinct
660 | on QueryBuilder {
661 | QueryBuilder distinctByCreatedAt() {
662 | return QueryBuilder.apply(this, (query) {
663 | return query.addDistinctBy(r'createdAt');
664 | });
665 | }
666 |
667 | QueryBuilder distinctByName(
668 | {bool caseSensitive = true}) {
669 | return QueryBuilder.apply(this, (query) {
670 | return query.addDistinctBy(r'name', caseSensitive: caseSensitive);
671 | });
672 | }
673 |
674 | QueryBuilder distinctByUpdatedAt() {
675 | return QueryBuilder.apply(this, (query) {
676 | return query.addDistinctBy(r'updatedAt');
677 | });
678 | }
679 | }
680 |
681 | extension CategoryQueryProperty
682 | on QueryBuilder {
683 | QueryBuilder idProperty() {
684 | return QueryBuilder.apply(this, (query) {
685 | return query.addPropertyName(r'id');
686 | });
687 | }
688 |
689 | QueryBuilder createdAtProperty() {
690 | return QueryBuilder.apply(this, (query) {
691 | return query.addPropertyName(r'createdAt');
692 | });
693 | }
694 |
695 | QueryBuilder nameProperty() {
696 | return QueryBuilder.apply(this, (query) {
697 | return query.addPropertyName(r'name');
698 | });
699 | }
700 |
701 | QueryBuilder updatedAtProperty() {
702 | return QueryBuilder.apply(this, (query) {
703 | return query.addPropertyName(r'updatedAt');
704 | });
705 | }
706 | }
707 |
--------------------------------------------------------------------------------
/lib/models/feed.dart:
--------------------------------------------------------------------------------
1 | import 'package:isar/isar.dart';
2 | import 'package:meread/models/category.dart';
3 | import 'package:meread/models/post.dart';
4 |
5 | part 'feed.g.dart';
6 |
7 | @collection
8 | class Feed {
9 | Id? id = Isar.autoIncrement;
10 | String title;
11 | String url;
12 | String description;
13 | final category = IsarLink();
14 | bool fullText;
15 | int openType; // 0: App Read, 1: In-app tab, 2: System browser
16 | @Backlink(to: 'feed')
17 | final posts = IsarLinks();
18 |
19 | Feed({
20 | this.id,
21 | required this.title,
22 | required this.url,
23 | required this.description,
24 | required this.fullText,
25 | required this.openType,
26 | });
27 | }
28 |
--------------------------------------------------------------------------------
/lib/models/post.dart:
--------------------------------------------------------------------------------
1 | import 'package:isar/isar.dart';
2 | import 'package:meread/models/feed.dart';
3 |
4 | part 'post.g.dart';
5 |
6 | @collection
7 | class Post {
8 | Id? id = Isar.autoIncrement;
9 | final feed = IsarLink();
10 | String title;
11 | String link;
12 | String content;
13 | DateTime pubDate;
14 | bool read;
15 | bool favorite;
16 | bool fullText;
17 |
18 | Post({
19 | this.id,
20 | required this.title,
21 | required this.link,
22 | required this.content,
23 | required this.pubDate,
24 | required this.read,
25 | required this.favorite,
26 | required this.fullText,
27 | });
28 | }
29 |
--------------------------------------------------------------------------------
/lib/translation/translation.dart:
--------------------------------------------------------------------------------
1 | import 'package:get/get.dart';
2 |
3 | class AppTranslation extends Translations {
4 | @override
5 | Map> get keys => {
6 | 'zh_CN': {
7 | 'MeRead': 'MeRead',
8 | 'confirm': '确定',
9 | 'cancel': '取消',
10 | 'open': '开启',
11 | 'close': '关闭',
12 |
13 | // Route: /
14 | 'markAllAsRead': '全标已读',
15 | 'fullTextSearch': '全文搜索',
16 | 'FeedIsEmpty': '订阅源为空,请添加订阅源后再尝试',
17 | 'refreshFailed': '@count 个订阅源更新失败',
18 | 'refreshSuccess': '更新成功',
19 | 'allFeeds': '全部订阅',
20 |
21 | // Route: /add_feed
22 | 'addFeed': '添加订阅',
23 | 'feedAddress': '订阅源地址',
24 | 'pasteAddress': '粘贴地址',
25 | 'resloveAddress': '解析地址',
26 | 'feedAlreadyExists': '订阅源已存在',
27 | 'feedResolveError': '解析订阅源失败',
28 | 'feedCategory': '订阅源分类',
29 | 'defaultCategory': '默认分类',
30 | 'fullText': '获取全文',
31 | 'fullTextInfo': '自动抓取文章全文内容',
32 | 'openType': '打开方式',
33 | 'openInApp': '内置阅读器',
34 | 'openInAppTab': '内置标签页',
35 | 'openInBrowser': '系统浏览器',
36 | 'saveFeed': '保存',
37 | 'deleteFeed': '删除',
38 |
39 | // Route: /edit_feed
40 | 'editFeed': '编辑订阅源',
41 | 'feedName': '订阅源名称',
42 |
43 | // Route: /setting
44 | 'moreSetting': '更多设置',
45 |
46 | // Route: /setting/display
47 | 'displaySetting': '显示设置',
48 | 'displaySettingInfo': '主题,动效,缩放,语言',
49 | 'darkMode': '深色模式',
50 | 'followSystem': '跟随系统',
51 | 'dynamicColor': '动态颜色',
52 | 'dynamicColorInfo': '根据壁纸自动调整主题颜色',
53 | 'globalFont': '全局字体',
54 | 'defaultFont': '默认字体',
55 | 'importFont': '导入',
56 | 'animationEffect': '动画效果',
57 | 'animationEffectInfo': '重启应用后生效',
58 | 'smoothScrolling': '平滑滚动',
59 | 'fadeInAndOut': '淡入淡出',
60 | 'textScale': '字体缩放',
61 | 'textScaleFactor': '字体缩放系数:',
62 | 'language': '语言',
63 | 'systemLanguage': '系统语言',
64 | 'zh_CN': '简体中文',
65 | 'en_US': 'English',
66 |
67 | // Route: /setting/read
68 | 'readSetting': '阅读设置',
69 | 'readSettingInfo': '字体,行高,边距,对齐',
70 | 'fontSize': '字体大小',
71 | 'lineHeight': '行高',
72 | 'pagePadding': '页面边距',
73 | 'textAlign': '文本对齐',
74 | 'leftAlign': '左对齐',
75 | 'rightAlign': '右对齐',
76 | 'justifyAlign': '两端对齐',
77 | 'centerAlign': '居中对齐',
78 |
79 | // Route: /setting/resolve
80 | 'resolveSetting': '解析设置',
81 | 'resolveSettingInfo': '启动时刷新,屏蔽词,使用代理',
82 | 'refreshOnStartup': '启动时刷新',
83 | 'refreshOnStartupInfo': '启动应用时自动拉取订阅源更新',
84 | 'blockWords': '屏蔽词',
85 | 'blockWordsInfo': '屏蔽包含关键词的文章',
86 | 'add': '添加',
87 | 'addBlockWord': '添加屏蔽词',
88 | 'useProxy': '使用代理',
89 | 'useProxyInfo': '网络请求时使用代理服务器',
90 | 'useProxyFailedInfo': '代理地址和端口不能为空',
91 | 'proxyAddress': '代理地址',
92 | 'proxyPort': '代理端口',
93 | 'notSet': '未设置',
94 |
95 | // Route: /setting/data_manage
96 | 'dataManage': '数据管理',
97 | 'dataManageInfo': '导入,导出,清除数据',
98 | 'importOpml': '导入 OPML',
99 | 'importOpmlInfo': '从 OPML 文件导入订阅源',
100 | 'exportOpml': '导出 OPML',
101 | 'exportOpmlInfo': '导出所有订阅源到 OPML 文件',
102 |
103 | // Route: /setting/about
104 | 'aboutApp': '关于应用',
105 | 'aboutAppInfo': '版本,开源地址,联系作者',
106 | 'appInfo': 'Material You 风格的 RSS 阅读器',
107 | 'openSource': '开源地址',
108 | 'contactAuthor': '联系作者',
109 | },
110 | 'en_US': {}
111 | };
112 | }
113 |
--------------------------------------------------------------------------------
/lib/ui/viewmodels/add_feed/add_feed_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:flutter/services.dart';
3 | import 'package:fluttertoast/fluttertoast.dart';
4 | import 'package:get/get.dart';
5 | import 'package:meread/helpers/isar_helper.dart';
6 | import 'package:meread/helpers/resolve_helper.dart';
7 | import 'package:meread/models/feed.dart';
8 |
9 | class AddFeedController extends GetxController {
10 | RxBool isResolved = false.obs;
11 |
12 | final addressController = TextEditingController();
13 | Feed? feed;
14 |
15 | Future pasteAddress() async {
16 | String? address = (await Clipboard.getData('text/plain'))?.text;
17 | if (address != null) {
18 | addressController.text = address;
19 | addressController.selection = TextSelection.fromPosition(
20 | TextPosition(offset: address.length),
21 | );
22 | }
23 | }
24 |
25 | Future resolveAddress() async {
26 | final url = addressController.text;
27 | feed = await ResolveHelper.parseFeed(url);
28 | if (feed == null) {
29 | Fluttertoast.showToast(msg: 'feedResolveError'.tr);
30 | }
31 | isResolved.value = true;
32 | }
33 |
34 | Future isExists() async {
35 | final url = addressController.text;
36 | final result = await IsarHelper.isExistsFeed(url);
37 | if (result != null) {
38 | feed = result;
39 | }
40 | return result != null;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/lib/ui/viewmodels/edit_feed/edit_feed_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/widgets.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/helpers/isar_helper.dart';
4 | import 'package:meread/models/category.dart';
5 | import 'package:meread/models/feed.dart';
6 |
7 | class EditFeedCntroller extends GetxController {
8 | RxBool fullText = false.obs;
9 | RxInt openType = 0.obs;
10 | final titleController = TextEditingController();
11 | final categoryController = TextEditingController();
12 | Feed? feed;
13 |
14 | void initFeed(Feed value) {
15 | fullText.value = value.fullText;
16 | openType.value = value.openType;
17 | titleController.text = value.title;
18 | categoryController.text = value.category.value?.name ?? '';
19 | feed = value;
20 | }
21 |
22 | void updateFullText(bool value) {
23 | fullText.value = value;
24 | }
25 |
26 | void updateOpenType(int value) {
27 | openType.value = value;
28 | }
29 |
30 | Future saveFeed() async {
31 | final newFeed = Feed(
32 | id: feed?.id,
33 | title: titleController.text,
34 | url: feed?.url ?? '',
35 | description: feed?.description ?? '',
36 | fullText: fullText.value,
37 | openType: openType.value,
38 | );
39 | final Category category =
40 | await IsarHelper.getCategoryByName(categoryController.text) ??
41 | Category(
42 | name: categoryController.text,
43 | createdAt: DateTime.now(),
44 | updatedAt: DateTime.now(),
45 | );
46 | newFeed.category.value = category;
47 | IsarHelper.saveFeed(newFeed);
48 | Get.back();
49 | }
50 |
51 | void deleteFeed() {
52 | if (feed == null || feed?.id == null) {
53 | Get.back();
54 | } else {
55 | IsarHelper.deleteFeed(feed!);
56 | Get.back();
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/lib/ui/viewmodels/home_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:fluttertoast/fluttertoast.dart';
3 | import 'package:get/get.dart';
4 | import 'package:meread/helpers/isar_helper.dart';
5 | import 'package:meread/helpers/resolve_helper.dart';
6 | import 'package:meread/models/category.dart';
7 | import 'package:meread/models/feed.dart';
8 | import 'package:meread/models/post.dart';
9 |
10 | class HomeController extends GetxController {
11 | RxList categorys = [].obs;
12 | RxMap unreadCount = {}.obs;
13 | RxList feeds = [].obs;
14 | RxList postList = [].obs;
15 | RxBool onlyUnread = false.obs;
16 | RxBool onlyFavorite = false.obs;
17 | RxString appBarTitle = 'MeRead'.tr.obs;
18 |
19 | final searchController = SearchController();
20 |
21 | @override
22 | void onInit() {
23 | super.onInit();
24 | getFeeds().then((_) => getPosts());
25 | getUnreadCount();
26 | }
27 |
28 | Future getFeeds() async {
29 | feeds.value = await IsarHelper.getFeeds();
30 | categorys.value = await IsarHelper.getCategorys();
31 | appBarTitle.value = 'MeRead'.tr;
32 | }
33 |
34 | Future getUnreadCount() async {
35 | final List posts = await IsarHelper.getPosts();
36 | final Map result = {};
37 | for (final Feed feed in feeds) {
38 | final int count =
39 | posts.where((p) => p.feed.value?.id == feed.id && !p.read).length;
40 | result[feed] = count;
41 | }
42 | unreadCount.value = result;
43 | }
44 |
45 | Future getPosts() async {
46 | postList.value = await IsarHelper.getPostsByFeeds(feeds);
47 | }
48 |
49 | Future refreshPosts() async {
50 | if (feeds.isEmpty) {
51 | Fluttertoast.showToast(msg: 'FeedIsEmpty'.tr);
52 | return;
53 | }
54 | List result = await ResolveHelper.reslovePosts(feeds);
55 | getPosts();
56 | if (result[1] > 0) {
57 | Fluttertoast.showToast(
58 | msg: 'refreshFailed'.trParams({'count': result[1].toString()}),
59 | );
60 | } else {
61 | Fluttertoast.showToast(msg: 'refreshSuccess'.tr);
62 | }
63 | }
64 |
65 | Future focusAllFeeds() async {
66 | feeds.value = await IsarHelper.getFeeds();
67 | await getPosts();
68 | onlyUnread.value = false;
69 | onlyFavorite.value = false;
70 | appBarTitle.value = 'MeRead'.tr;
71 | Get.back();
72 | }
73 |
74 | // Focus on a category
75 | Future focusCategory(Category category) async {
76 | feeds.value = category.feeds.toList();
77 | await getPosts();
78 | onlyUnread.value = false;
79 | onlyFavorite.value = false;
80 | appBarTitle.value = category.name;
81 | Get.back();
82 | }
83 |
84 | // Focus on a feed
85 | Future focusFeed(Feed feed) async {
86 | feeds.value = [feed];
87 | await getPosts();
88 | onlyUnread.value = false;
89 | onlyFavorite.value = false;
90 | appBarTitle.value = feed.title;
91 | Get.back();
92 | }
93 |
94 | // Filter unread
95 | Future filterUnread() async {
96 | if (onlyUnread.value) {
97 | onlyUnread.value = false;
98 | getPosts();
99 | } else {
100 | onlyUnread.value = true;
101 | onlyFavorite.value = false;
102 | postList.value = (await IsarHelper.getPostsByFeeds(feeds))
103 | .where((p) => p.read == false)
104 | .toList();
105 | }
106 | }
107 |
108 | // Filter favorite
109 | Future filterFavorite() async {
110 | if (onlyFavorite.value) {
111 | onlyFavorite.value = false;
112 | getPosts();
113 | } else {
114 | onlyFavorite.value = true;
115 | onlyUnread.value = false;
116 | postList.value = (await IsarHelper.getPostsByFeeds(feeds))
117 | .where((p) => p.favorite)
118 | .toList();
119 | }
120 | }
121 |
122 | // Update a Post read status
123 | void updateReadStatus(Post post) {
124 | final int index = postList.indexOf(post);
125 | IsarHelper.updatePostRead(post);
126 | postList[index] = post;
127 | }
128 |
129 | // Mark all posts as read
130 | void markAllRead() {
131 | IsarHelper.markAllRead(postList);
132 | getPosts();
133 | }
134 |
135 | // Go to add feed view
136 | void toAddFeed() {
137 | Get.toNamed('/addFeed')?.then((_) => getFeeds().then((_) {
138 | getPosts();
139 | getUnreadCount();
140 | }));
141 | }
142 |
143 | // Go to setting view
144 | void toSetting() {
145 | Get.toNamed('/setting')?.then((_) => getFeeds().then((_) {
146 | getPosts();
147 | getUnreadCount();
148 | }));
149 | }
150 |
151 | // Go to edit feed view
152 | void toEditFeed(Feed value) {
153 | Get.toNamed('/editFeed', arguments: value)
154 | ?.then((_) => getFeeds().then((_) {
155 | getPosts();
156 | getUnreadCount();
157 | }));
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/lib/ui/viewmodels/post/post_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:get/get.dart';
2 | import 'package:html/parser.dart' as html_parser;
3 | import 'package:html_main_element/html_main_element.dart';
4 | import 'package:meread/helpers/dio_helper.dart';
5 | import 'package:meread/helpers/isar_helper.dart';
6 | import 'package:meread/helpers/log_helper.dart';
7 | import 'package:meread/models/post.dart';
8 | import 'package:url_launcher/url_launcher_string.dart';
9 |
10 | class PostController extends GetxController {
11 | late Rx post;
12 | RxBool fullTexting = false.obs;
13 |
14 | PostController(Post p) {
15 | p.read = true;
16 | IsarHelper.savePost(p);
17 | post = p.obs;
18 | if ((post.value.feed.value?.fullText ?? false) && !post.value.fullText) {
19 | fullTexting.value = true;
20 | getFullText();
21 | }
22 | }
23 |
24 | // 在浏览器中打开
25 | void openInBrowser() {
26 | launchUrlString(
27 | post.value.link,
28 | mode: LaunchMode.externalApplication,
29 | );
30 | }
31 |
32 | // 获取全文
33 | Future getFullText() async {
34 | fullTexting.value = true;
35 | try {
36 | final response = await DioHelper.get(post.value.link);
37 | final document = html_parser.parse(response.data.toString());
38 | if (document.documentElement == null) return;
39 | final mainElement = readabilityMainElement(document.documentElement!);
40 | post.value = Post(
41 | id: post.value.id,
42 | title: post.value.title,
43 | link: post.value.link,
44 | content: mainElement.outerHtml,
45 | pubDate: post.value.pubDate,
46 | read: post.value.read,
47 | favorite: post.value.favorite,
48 | fullText: true,
49 | )..feed.value = post.value.feed.value;
50 | fullTexting.value = false;
51 | IsarHelper.savePost(post.value);
52 | } catch (e) {
53 | LogHelper.e(e);
54 | }
55 | }
56 |
57 | // 标记为未读
58 | void markAsUnread() {
59 | post.value.read = false;
60 | IsarHelper.savePost(post.value);
61 | }
62 |
63 | // 更改收藏状态
64 | void changeFavorite() {
65 | post.value = Post(
66 | id: post.value.id,
67 | title: post.value.title,
68 | link: post.value.link,
69 | content: post.value.content,
70 | pubDate: post.value.pubDate,
71 | read: post.value.read,
72 | favorite: !post.value.favorite,
73 | fullText: post.value.fullText,
74 | )..feed.value = post.value.feed.value;
75 | IsarHelper.savePost(post.value);
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/lib/ui/viewmodels/settings/display/display_setting_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/helpers/font_helper.dart';
4 | import 'package:meread/helpers/prefs_helper.dart';
5 |
6 | class DisplaySettingController extends GetxController {
7 | RxInt themeMode = PrefsHelper.themeMode.obs;
8 | RxBool enableDynamicColor = PrefsHelper.useDynamicColor.obs;
9 | RxString globalFont = PrefsHelper.themeFont.obs;
10 | RxList fontList = ["system"].obs;
11 | RxString transition = PrefsHelper.transition.obs;
12 | RxDouble textScaleFactor = PrefsHelper.textScaleFactor.obs;
13 | RxString language = PrefsHelper.language.obs;
14 |
15 | List get languageList => [
16 | 'system',
17 | 'zh_CN',
18 | 'en_US',
19 | ];
20 |
21 | void changeThemeMode(int value) {
22 | if (value == themeMode.value) return;
23 | themeMode.value = value;
24 | PrefsHelper.themeMode = value;
25 | Get.changeThemeMode([
26 | ThemeMode.system,
27 | ThemeMode.light,
28 | ThemeMode.dark,
29 | ][value]);
30 | }
31 |
32 | void changeEnableDynamicColor(bool value) {
33 | if (value == enableDynamicColor.value) return;
34 | enableDynamicColor.value = value;
35 | PrefsHelper.useDynamicColor = value;
36 | Get.forceAppUpdate();
37 | }
38 |
39 | void changeGlobalFont(String value) {
40 | if (value == globalFont.value) return;
41 | globalFont.value = value;
42 | PrefsHelper.themeFont = value;
43 | Get.forceAppUpdate();
44 | }
45 |
46 | void changeTransition(String value) {
47 | if (value == transition.value) return;
48 | transition.value = value;
49 | PrefsHelper.transition = value;
50 | Get.forceAppUpdate();
51 | }
52 |
53 | void changeTextScaleFactor(double value) {
54 | if (value == textScaleFactor.value) return;
55 | textScaleFactor.value = value;
56 | PrefsHelper.textScaleFactor = value;
57 | Get.forceAppUpdate();
58 | }
59 |
60 | void changeLanguage(String value) {
61 | if (value == language.value) return;
62 | language.value = value;
63 | PrefsHelper.language = value;
64 | if (value != 'system') {
65 | Get.updateLocale(Locale(value.split('_').first, value.split('_').last));
66 | } else {
67 | Get.updateLocale(Get.deviceLocale ?? const Locale('en', 'US'));
68 | }
69 | }
70 |
71 | Future deleteFont(String font) async {
72 | await FontHelper.deleteFont(font);
73 | await refreshFontList();
74 | changeGlobalFont("system");
75 | }
76 |
77 | Future refreshFontList() async {
78 | await FontHelper.readAllFont().then(
79 | (value) => fontList.value = ["system", ...value],
80 | );
81 | }
82 |
83 | // import font
84 | Future importFont() async {
85 | await FontHelper.loadLocalFont();
86 | await refreshFontList();
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/lib/ui/viewmodels/settings/read/read_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:get/get.dart';
2 | import 'package:meread/helpers/prefs_helper.dart';
3 |
4 | class ReadController extends GetxController {
5 | RxInt fontSize = PrefsHelper.readFontSize.obs;
6 | RxDouble lineHeight = PrefsHelper.readLineHeight.obs;
7 | RxInt pagePadding = PrefsHelper.readPagePadding.obs;
8 | RxString textAlign = PrefsHelper.readTextAlign.obs;
9 |
10 | void changeFontSize(int value) {
11 | if (value == fontSize.value) return;
12 | fontSize.value = value;
13 | PrefsHelper.readFontSize = value;
14 | }
15 |
16 | void changeLineHeight(double value) {
17 | if (value == lineHeight.value) return;
18 | lineHeight.value = value;
19 | PrefsHelper.readLineHeight = value;
20 | }
21 |
22 | void changePagePadding(int value) {
23 | if (value == pagePadding.value) return;
24 | pagePadding.value = value;
25 | PrefsHelper.readPagePadding = value;
26 | }
27 |
28 | void changeTextAlign(String value) {
29 | if (value == textAlign.value) return;
30 | textAlign.value = value;
31 | PrefsHelper.readTextAlign = value;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/lib/ui/viewmodels/settings/resolve/resolve_setting_controller.dart:
--------------------------------------------------------------------------------
1 | import 'package:fluttertoast/fluttertoast.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/helpers/prefs_helper.dart';
4 |
5 | class ResolveSettingController extends GetxController {
6 | RxBool refreshOnStartup = PrefsHelper.refreshOnStartup.obs;
7 | RxList blockWords = PrefsHelper.blockList.obs;
8 | RxBool useProxy = PrefsHelper.useProxy.obs;
9 | RxString proxyAddress = PrefsHelper.proxyAddress.obs;
10 | RxString proxyPort = PrefsHelper.proxyPort.obs;
11 |
12 | void changeRefreshOnStartup(bool value) {
13 | if (value == refreshOnStartup.value) return;
14 | refreshOnStartup.value = value;
15 | PrefsHelper.refreshOnStartup = value;
16 | }
17 |
18 | void changeBlockWords(List value) {
19 | if (value == blockWords) return;
20 | blockWords.assignAll(value);
21 | PrefsHelper.blockList = value;
22 | }
23 |
24 | void changeUseProxy(bool value) {
25 | if (proxyAddress.value.isEmpty || proxyPort.value.isEmpty) {
26 | Fluttertoast.showToast(msg: 'useProxyFailedInfo'.tr);
27 | return;
28 | }
29 | if (value == useProxy.value) return;
30 | useProxy.value = value;
31 | PrefsHelper.useProxy = value;
32 | }
33 |
34 | void changeProxyAddress(String value) {
35 | if (value == proxyAddress.value || value.isEmpty) return;
36 | proxyAddress.value = value;
37 | PrefsHelper.proxyAddress = value;
38 | }
39 |
40 | void changeProxyPort(String value) {
41 | if (value == proxyPort.value || value.isEmpty) return;
42 | proxyPort.value = value;
43 | PrefsHelper.proxyPort = value;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/lib/ui/views/add_feed/add_feed_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:fluttertoast/fluttertoast.dart';
3 | import 'package:get/get.dart';
4 | import 'package:meread/ui/viewmodels/add_feed/add_feed_controller.dart';
5 |
6 | class AddFeedView extends StatelessWidget {
7 | const AddFeedView({super.key});
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | final c = Get.put(AddFeedController());
12 | return Scaffold(
13 | body: CustomScrollView(
14 | physics: const BouncingScrollPhysics(),
15 | slivers: [
16 | SliverAppBar.large(
17 | title: Text('addFeed'.tr),
18 | ),
19 | SliverList.list(
20 | children: [
21 | Padding(
22 | padding: const EdgeInsets.symmetric(horizontal: 18),
23 | child: Text('feedAddress'.tr),
24 | ),
25 | Padding(
26 | padding: const EdgeInsets.symmetric(horizontal: 18),
27 | child: TextField(
28 | controller: c.addressController,
29 | ),
30 | ),
31 | Padding(
32 | padding: const EdgeInsets.symmetric(
33 | horizontal: 18,
34 | vertical: 12,
35 | ),
36 | child: Row(
37 | children: [
38 | Expanded(
39 | flex: 1,
40 | child: FilledButton.tonal(
41 | onPressed: c.pasteAddress,
42 | child: Text('pasteAddress'.tr),
43 | ),
44 | ),
45 | const SizedBox(width: 12),
46 | Expanded(
47 | flex: 1,
48 | child: FilledButton.tonal(
49 | onPressed: c.resolveAddress,
50 | child: Text('resloveAddress'.tr),
51 | ),
52 | ),
53 | ],
54 | ),
55 | ),
56 | AnimatedSwitcher(
57 | duration: const Duration(milliseconds: 300),
58 | transitionBuilder: (child, animation) {
59 | return FadeTransition(
60 | opacity: animation,
61 | child: child,
62 | );
63 | },
64 | child: Obx(() {
65 | if (c.isResolved.value && c.feed != null) {
66 | return GestureDetector(
67 | onTap: () async {
68 | final bool isExist = await c.isExists();
69 | if (isExist) {
70 | Fluttertoast.showToast(
71 | msg: 'feedAlreadyExists'.tr,
72 | );
73 | return;
74 | }
75 | Get.toNamed('/editFeed', arguments: c.feed)!
76 | .then((_) => Get.back());
77 | },
78 | child: Container(
79 | width: double.maxFinite,
80 | padding: const EdgeInsets.all(12),
81 | margin: const EdgeInsets.symmetric(horizontal: 18),
82 | decoration: BoxDecoration(
83 | color: Get.theme.colorScheme.secondaryContainer,
84 | borderRadius: BorderRadius.circular(12),
85 | ),
86 | child: Column(
87 | crossAxisAlignment: CrossAxisAlignment.start,
88 | children: [
89 | Text(
90 | c.feed!.title,
91 | style: const TextStyle(fontSize: 20),
92 | ),
93 | const SizedBox(height: 4),
94 | Text(c.feed!.description),
95 | ],
96 | ),
97 | ),
98 | );
99 | }
100 | return const SizedBox.shrink();
101 | }),
102 | ),
103 | ],
104 | ),
105 | ],
106 | ),
107 | );
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/lib/ui/views/edit_feed/edit_feed_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/models/feed.dart';
4 | import 'package:meread/ui/viewmodels/edit_feed/edit_feed_controller.dart';
5 |
6 | class EditFeedView extends StatelessWidget {
7 | const EditFeedView({super.key});
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | Feed feed = Get.arguments;
12 | final c = Get.put(EditFeedCntroller());
13 | c.initFeed(feed);
14 | final ColorScheme colorScheme = Get.theme.colorScheme;
15 | return Scaffold(
16 | body: CustomScrollView(
17 | physics: const BouncingScrollPhysics(),
18 | slivers: [
19 | SliverAppBar.large(
20 | title: Text('editFeed'.tr),
21 | ),
22 | SliverList.list(
23 | children: [
24 | Padding(
25 | padding: const EdgeInsets.symmetric(horizontal: 18),
26 | child: Text(
27 | 'feedAddress'.tr,
28 | style: TextStyle(color: colorScheme.primary),
29 | ),
30 | ),
31 | Padding(
32 | padding: const EdgeInsets.symmetric(horizontal: 18),
33 | child: TextField(
34 | controller: TextEditingController(text: feed.url),
35 | enabled: false,
36 | ),
37 | ),
38 | const SizedBox(height: 18),
39 | Padding(
40 | padding: const EdgeInsets.symmetric(horizontal: 18),
41 | child: Text(
42 | 'feedName'.tr,
43 | style: TextStyle(color: colorScheme.primary),
44 | ),
45 | ),
46 | Padding(
47 | padding: const EdgeInsets.symmetric(horizontal: 18),
48 | child: TextField(
49 | controller: c.titleController,
50 | ),
51 | ),
52 | const SizedBox(height: 18),
53 | Padding(
54 | padding: const EdgeInsets.symmetric(horizontal: 18),
55 | child: Text(
56 | 'feedCategory'.tr,
57 | style: TextStyle(color: colorScheme.primary),
58 | ),
59 | ),
60 | Padding(
61 | padding: const EdgeInsets.symmetric(horizontal: 18),
62 | child: TextField(
63 | controller: c.categoryController,
64 | ),
65 | ),
66 | const SizedBox(height: 18),
67 | Obx(
68 | () => SwitchListTile(
69 | value: c.fullText.value,
70 | onChanged: (value) => c.updateFullText(value),
71 | title: Text('fullText'.tr),
72 | subtitle: Text('fullTextInfo'.tr),
73 | ),
74 | ),
75 | const SizedBox(height: 18),
76 | Padding(
77 | padding: const EdgeInsets.fromLTRB(18, 0, 18, 8),
78 | child: Text(
79 | 'openType'.tr,
80 | style: TextStyle(color: colorScheme.primary),
81 | ),
82 | ),
83 | for (int i = 0; i < 3; i++)
84 | RadioListTile(
85 | value: i,
86 | groupValue: c.feed?.openType ?? 0,
87 | title: Text([
88 | 'openInApp'.tr,
89 | 'openInAppTab'.tr,
90 | 'openInBrowser'.tr
91 | ][i]),
92 | onChanged: (value) {
93 | if (value != null) c.updateOpenType(value);
94 | },
95 | ),
96 | Padding(
97 | padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
98 | child: FilledButton.tonal(
99 | onPressed: c.saveFeed,
100 | child: Text('saveFeed'.tr),
101 | ),
102 | ),
103 | Padding(
104 | padding: const EdgeInsets.fromLTRB(12, 6, 12, 48),
105 | child: FilledButton.tonal(
106 | onPressed: c.deleteFeed,
107 | child: Text('deleteFeed'.tr),
108 | ),
109 | ),
110 | ],
111 | ),
112 | ],
113 | ),
114 | );
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/lib/ui/views/home_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:flutter_swipe_action_cell/flutter_swipe_action_cell.dart';
3 | import 'package:get/get.dart';
4 | import 'package:meread/helpers/isar_helper.dart';
5 | import 'package:meread/helpers/prefs_helper.dart';
6 | import 'package:meread/models/category.dart';
7 | import 'package:meread/models/post.dart';
8 | import 'package:meread/ui/viewmodels/home_controller.dart';
9 | import 'package:meread/ui/widgets/feed_panel.dart';
10 | import 'package:meread/ui/widgets/post_card.dart';
11 |
12 | class HomeView extends StatefulWidget {
13 | const HomeView({super.key});
14 |
15 | @override
16 | State createState() => _HomeViewState();
17 | }
18 |
19 | class _HomeViewState extends State {
20 | final GlobalKey _refreshKey = GlobalKey();
21 | final c = Get.put(HomeController());
22 |
23 | @override
24 | void initState() {
25 | super.initState();
26 | if (PrefsHelper.refreshOnStartup) {
27 | WidgetsBinding.instance.addPostFrameCallback((_) {
28 | _refreshKey.currentState?.show();
29 | });
30 | }
31 | }
32 |
33 | @override
34 | Widget build(BuildContext context) {
35 | return Scaffold(
36 | appBar: AppBar(
37 | title: Obx(() => Text(
38 | c.appBarTitle.value,
39 | )),
40 | centerTitle: false,
41 | actions: [
42 | IconButton(
43 | onPressed: c.filterUnread,
44 | icon: Obx(() => c.onlyUnread.value
45 | ? const Icon(Icons.radio_button_checked)
46 | : const Icon(Icons.radio_button_unchecked)),
47 | ),
48 | IconButton(
49 | onPressed: c.filterFavorite,
50 | icon: Obx(() => c.onlyFavorite.value
51 | ? const Icon(Icons.bookmark)
52 | : const Icon(Icons.bookmark_border_outlined)),
53 | ),
54 | PopupMenuButton(
55 | elevation: 1,
56 | position: PopupMenuPosition.under,
57 | itemBuilder: (BuildContext context) {
58 | return [
59 | PopupMenuItem(
60 | onTap: c.markAllRead,
61 | padding: const EdgeInsets.symmetric(horizontal: 16),
62 | child: Row(
63 | crossAxisAlignment: CrossAxisAlignment.center,
64 | children: [
65 | const Icon(Icons.done_all_outlined, size: 20),
66 | const SizedBox(width: 10),
67 | Text('markAllAsRead'.tr),
68 | ],
69 | ),
70 | ),
71 | const PopupMenuDivider(),
72 | PopupMenuItem(
73 | child: SearchAnchor(
74 | isFullScreen: true,
75 | searchController: c.searchController,
76 | builder: (context, controller) {
77 | return Row(
78 | crossAxisAlignment: CrossAxisAlignment.center,
79 | children: [
80 | const Icon(Icons.search_outlined, size: 20),
81 | const SizedBox(width: 10),
82 | Text('fullTextSearch'.tr),
83 | ],
84 | );
85 | },
86 | suggestionsBuilder: (BuildContext context,
87 | SearchController controller) async {
88 | List results =
89 | await IsarHelper.search(controller.text);
90 | return results
91 | .map((e) => Padding(
92 | padding: const EdgeInsets.symmetric(
93 | horizontal: 12, vertical: 4),
94 | child: PostCard(post: e),
95 | ))
96 | .toList();
97 | },
98 | ),
99 | ),
100 | PopupMenuItem(
101 | onTap: c.toAddFeed,
102 | child: Row(
103 | crossAxisAlignment: CrossAxisAlignment.center,
104 | children: [
105 | const Icon(Icons.add_outlined, size: 20),
106 | const SizedBox(width: 10),
107 | Text('addFeed'.tr),
108 | ],
109 | ),
110 | ),
111 | const PopupMenuDivider(),
112 | PopupMenuItem(
113 | onTap: c.toSetting,
114 | child: Row(
115 | crossAxisAlignment: CrossAxisAlignment.center,
116 | children: [
117 | const Icon(Icons.settings_outlined, size: 20),
118 | const SizedBox(width: 10),
119 | Text('moreSetting'.tr),
120 | ],
121 | ),
122 | ),
123 | ];
124 | },
125 | ),
126 | ],
127 | ),
128 | body: SafeArea(
129 | child: RefreshIndicator(
130 | key: _refreshKey,
131 | onRefresh: c.refreshPosts,
132 | child: Obx(
133 | () => ListView.separated(
134 | physics: c.postList.isEmpty
135 | ? const AlwaysScrollableScrollPhysics()
136 | : const BouncingScrollPhysics(),
137 | padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
138 | itemBuilder: (context, index) {
139 | return SwipeActionCell(
140 | key: ObjectKey(c.postList[index]),
141 | trailingActions: [
142 | SwipeAction(
143 | color: Colors.transparent,
144 | content: Container(
145 | width: 50,
146 | height: 50,
147 | decoration: BoxDecoration(
148 | borderRadius: BorderRadius.circular(25),
149 | color:
150 | Theme.of(context).colorScheme.secondaryContainer,
151 | ),
152 | child: Icon(
153 | Icons.done_outline_rounded,
154 | color: Theme.of(context)
155 | .colorScheme
156 | .onSecondaryContainer,
157 | ),
158 | ),
159 | onTap: (handler) async {
160 | c.updateReadStatus(c.postList[index]);
161 | c.getUnreadCount();
162 | await handler(false);
163 | },
164 | ),
165 | ],
166 | child: InkWell(
167 | onTap: () {
168 | Get.toNamed('/post', arguments: c.postList[index])!
169 | .then((_) {
170 | c.getPosts();
171 | c.getUnreadCount();
172 | });
173 | },
174 | child: PostCard(post: c.postList[index]),
175 | ),
176 | );
177 | },
178 | separatorBuilder: (context, index) => const SizedBox(height: 8),
179 | itemCount: c.postList.length,
180 | ),
181 | ),
182 | ),
183 | ),
184 | drawerEdgeDragWidth: Get.width * 0.3,
185 | drawer: Drawer(
186 | child: SafeArea(
187 | child: Obx(
188 | () => ListView(
189 | physics: const BouncingScrollPhysics(),
190 | padding: const EdgeInsets.symmetric(vertical: 12),
191 | children: [
192 | Padding(
193 | padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
194 | child: ListTile(
195 | title: Text('allFeeds'.tr),
196 | onTap: c.focusAllFeeds,
197 | tileColor: Theme.of(context)
198 | .colorScheme
199 | .secondaryContainer
200 | .withAlpha(100),
201 | visualDensity: VisualDensity.compact,
202 | shape: RoundedRectangleBorder(
203 | borderRadius: BorderRadius.circular(24),
204 | ),
205 | ),
206 | ),
207 | for (Category category in c.categorys)
208 | FeedPanel(
209 | category: category,
210 | categoryOnTap: () => c.focusCategory(category),
211 | feedOnTap: (feed) => c.focusFeed(feed),
212 | ),
213 | ],
214 | ),
215 | ),
216 | ),
217 | ),
218 | );
219 | }
220 | }
221 |
--------------------------------------------------------------------------------
/lib/ui/views/post/post_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:flutter/services.dart';
3 | import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
4 | import 'package:get/get.dart';
5 | import 'package:meread/helpers/prefs_helper.dart';
6 | import 'package:meread/models/post.dart';
7 | import 'package:meread/ui/viewmodels/post/post_controller.dart';
8 | import 'package:share_plus/share_plus.dart';
9 | import 'package:url_launcher/url_launcher_string.dart';
10 |
11 | class PostView extends StatelessWidget {
12 | const PostView({super.key});
13 |
14 | @override
15 | Widget build(BuildContext context) {
16 | final Post p = Get.arguments;
17 | final c = Get.put(PostController(p));
18 | return Scaffold(
19 | appBar: AppBar(
20 | title: Text(c.post.value.feed.value?.title ?? ''),
21 | actions: [
22 | IconButton(
23 | onPressed: c.openInBrowser,
24 | icon: const Icon(Icons.open_in_browser_outlined),
25 | ),
26 | IconButton(
27 | onPressed: c.getFullText,
28 | icon: const Icon(Icons.article_outlined),
29 | ),
30 | PopupMenuButton(
31 | elevation: 1,
32 | position: PopupMenuPosition.under,
33 | itemBuilder: (BuildContext context) {
34 | return [
35 | PopupMenuItem(
36 | padding: const EdgeInsets.symmetric(horizontal: 16),
37 | onTap: c.markAsUnread,
38 | child: Row(
39 | mainAxisSize: MainAxisSize.min,
40 | children: [
41 | const Icon(Icons.visibility_off_outlined, size: 20),
42 | const SizedBox(width: 10),
43 | Text('markAsUnread'.tr),
44 | ],
45 | ),
46 | ),
47 | PopupMenuItem(
48 | onTap: c.changeFavorite,
49 | child: Row(
50 | mainAxisSize: MainAxisSize.min,
51 | children: [
52 | const Icon(Icons.bookmark_border_outlined, size: 20),
53 | const SizedBox(width: 10),
54 | Obx(() => Text(
55 | c.post.value.favorite
56 | ? 'cancelFavorite'.tr
57 | : 'markAsFavorite'.tr,
58 | )),
59 | ],
60 | ),
61 | ),
62 | const PopupMenuDivider(height: 0),
63 | PopupMenuItem(
64 | onTap: () {
65 | Clipboard.setData(ClipboardData(text: c.post.value.link));
66 | },
67 | child: Row(
68 | mainAxisSize: MainAxisSize.min,
69 | children: [
70 | const Icon(Icons.link_outlined, size: 20),
71 | const SizedBox(width: 10),
72 | Text('copyLink'.tr),
73 | ],
74 | ),
75 | ),
76 | PopupMenuItem(
77 | onTap: () {
78 | Share.share(
79 | '${c.post.value.title}\n${c.post.value.link}',
80 | subject: c.post.value.title,
81 | );
82 | },
83 | child: Row(
84 | mainAxisSize: MainAxisSize.min,
85 | children: [
86 | const Icon(Icons.share_outlined, size: 20),
87 | const SizedBox(width: 10),
88 | Text('sharePost'.tr),
89 | ],
90 | ),
91 | ),
92 | ];
93 | },
94 | ),
95 | ],
96 | ),
97 | body: SafeArea(
98 | child: SelectionArea(
99 | child: SingleChildScrollView(
100 | physics: const BouncingScrollPhysics(),
101 | padding: EdgeInsets.symmetric(
102 | vertical: 18,
103 | horizontal: PrefsHelper.readPagePadding.toDouble(),
104 | ),
105 | child: Obx(
106 | () => c.fullTexting.value
107 | ? Center(
108 | child: SizedBox(
109 | height: 200,
110 | width: 200,
111 | child: Column(
112 | mainAxisAlignment: MainAxisAlignment.center,
113 | crossAxisAlignment: CrossAxisAlignment.center,
114 | children: [
115 | const CircularProgressIndicator(),
116 | const SizedBox(height: 12),
117 | Text('fullTextLoading'.tr),
118 | ],
119 | ),
120 | ),
121 | )
122 | : HtmlWidget(
123 | '${c.post.value.title}
${c.post.value.content}',
124 | textStyle: TextStyle(
125 | fontSize: PrefsHelper.readFontSize.toDouble(),
126 | height: PrefsHelper.readLineHeight,
127 | ),
128 | onTapUrl: (url) {
129 | launchUrlString(
130 | url,
131 | mode: LaunchMode.externalApplication,
132 | );
133 | return true;
134 | },
135 | onLoadingBuilder: (context, element, progress) {
136 | return Center(
137 | child: SizedBox(
138 | height: 200,
139 | width: 200,
140 | child: Column(
141 | mainAxisAlignment: MainAxisAlignment.center,
142 | crossAxisAlignment: CrossAxisAlignment.center,
143 | children: [
144 | const CircularProgressIndicator(),
145 | const SizedBox(height: 12),
146 | Text('loading'.tr),
147 | ],
148 | ),
149 | ),
150 | );
151 | },
152 | customStylesBuilder: (element) {
153 | if (element.localName == 'h1') {
154 | return {
155 | 'font-size': '1.8em',
156 | 'line-height': '1.3em',
157 | 'text-align': PrefsHelper.readTextAlign,
158 | };
159 | }
160 | return {
161 | 'text-align': PrefsHelper.readTextAlign,
162 | };
163 | },
164 | customWidgetBuilder: (element) {
165 | if (element.localName == 'figure') {
166 | if (element.children.length == 1 &&
167 | element.children[0].localName == 'img') {
168 | String? imgUrl =
169 | element.children[0].attributes['src'];
170 | if (imgUrl != null) {
171 | return ImgForRead(
172 | url: element.children[0].attributes['src']!,
173 | );
174 | }
175 | }
176 | if (element.children.length == 2 &&
177 | element.children[0].localName == 'img' &&
178 | element.children[1].localName == 'figcaption') {
179 | String? imgUrl =
180 | element.children[0].attributes['src'];
181 | if (imgUrl != null) {
182 | return Column(
183 | children: [
184 | ImgForRead(
185 | url: element.children[0].attributes['src']!,
186 | ),
187 | Text(
188 | element.children[1].text,
189 | style: TextStyle(
190 | fontSize: PrefsHelper.readFontSize - 4,
191 | color:
192 | Theme.of(context).colorScheme.outline,
193 | height: PrefsHelper.readLineHeight,
194 | ),
195 | ),
196 | ],
197 | );
198 | }
199 | }
200 | }
201 | if (element.localName == 'img') {
202 | if (element.attributes['src'] != null) {
203 | return ImgForRead(
204 | url: element.attributes['src']!,
205 | );
206 | }
207 | }
208 | return null;
209 | },
210 | ),
211 | ),
212 | ),
213 | ),
214 | ),
215 | );
216 | }
217 | }
218 |
219 | class ImgForRead extends StatelessWidget {
220 | const ImgForRead({super.key, required this.url});
221 |
222 | final String? url;
223 |
224 | @override
225 | Widget build(BuildContext context) {
226 | if (url == null) {
227 | return const SizedBox.shrink();
228 | }
229 | return Image.network(
230 | url!,
231 | fit: BoxFit.cover,
232 | width: double.infinity,
233 | loadingBuilder: (context, child, loadingProgress) {
234 | if (loadingProgress == null) {
235 | return child;
236 | }
237 | return Center(
238 | child: Container(
239 | height: 200,
240 | width: double.infinity,
241 | decoration: BoxDecoration(
242 | borderRadius: BorderRadius.circular(8),
243 | ),
244 | child: Column(
245 | mainAxisAlignment: MainAxisAlignment.center,
246 | crossAxisAlignment: CrossAxisAlignment.center,
247 | children: [
248 | const CircularProgressIndicator(
249 | strokeWidth: 3,
250 | ),
251 | const SizedBox(height: 8),
252 | Text('imageLoading'.tr),
253 | ],
254 | ),
255 | ),
256 | );
257 | },
258 | errorBuilder: (context, error, stackTrace) {
259 | return Center(
260 | child: Container(
261 | height: 200,
262 | width: double.infinity,
263 | decoration: BoxDecoration(
264 | borderRadius: BorderRadius.circular(8),
265 | ),
266 | child: Column(
267 | mainAxisAlignment: MainAxisAlignment.center,
268 | crossAxisAlignment: CrossAxisAlignment.center,
269 | children: [
270 | const Icon(Icons.broken_image_outlined),
271 | const SizedBox(height: 8),
272 | Text('imageLoadError'.tr),
273 | ],
274 | ),
275 | ),
276 | );
277 | },
278 | );
279 | }
280 | }
281 |
--------------------------------------------------------------------------------
/lib/ui/views/setting/about/about_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/helpers/constant_helper.dart';
4 | import 'package:url_launcher/url_launcher_string.dart';
5 |
6 | class AboutView extends StatelessWidget {
7 | const AboutView({super.key});
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return Scaffold(
12 | appBar: AppBar(),
13 | body: SafeArea(
14 | child: Center(
15 | child: Column(
16 | mainAxisAlignment: MainAxisAlignment.start,
17 | crossAxisAlignment: CrossAxisAlignment.center,
18 | children: [
19 | Container(
20 | margin: const EdgeInsets.symmetric(vertical: 24),
21 | decoration: BoxDecoration(
22 | borderRadius: BorderRadius.circular(720),
23 | boxShadow: [
24 | BoxShadow(
25 | color:
26 | Theme.of(context).colorScheme.primary.withAlpha(10),
27 | spreadRadius: 10,
28 | blurRadius: 10,
29 | offset: const Offset(0, 3), // changes position of shadow
30 | ),
31 | ],
32 | ),
33 | clipBehavior: Clip.antiAlias,
34 | child: Image.asset(
35 | 'assets/meread.png',
36 | height: Get.mediaQuery.size.width / 3,
37 | ),
38 | ),
39 | Text(
40 | 'MeRead'.tr,
41 | style: const TextStyle(fontSize: 36),
42 | ),
43 | Padding(
44 | padding: const EdgeInsets.fromLTRB(18, 4, 18, 4),
45 | child: Text(
46 | 'appInfo'.tr,
47 | textAlign: TextAlign.center,
48 | style: const TextStyle(fontSize: 20),
49 | ),
50 | ),
51 | Text(
52 | 'Version ${ConstantHelp.appVersion}',
53 | textAlign: TextAlign.center,
54 | style: const TextStyle(fontSize: 16),
55 | ),
56 | Padding(
57 | padding:
58 | const EdgeInsets.symmetric(horizontal: 88, vertical: 88),
59 | child: Row(
60 | mainAxisAlignment: MainAxisAlignment.center,
61 | crossAxisAlignment: CrossAxisAlignment.center,
62 | children: [
63 | Column(
64 | mainAxisSize: MainAxisSize.min,
65 | mainAxisAlignment: MainAxisAlignment.center,
66 | crossAxisAlignment: CrossAxisAlignment.center,
67 | children: [
68 | IconButton.outlined(
69 | onPressed: () {
70 | launchUrlString(ConstantHelp.githubUrl);
71 | },
72 | icon: const Icon(Icons.code_rounded),
73 | iconSize: 36,
74 | ),
75 | const SizedBox(height: 6),
76 | Text('openSource'.tr),
77 | ],
78 | ),
79 | const SizedBox(width: 36),
80 | Column(
81 | mainAxisSize: MainAxisSize.min,
82 | mainAxisAlignment: MainAxisAlignment.center,
83 | crossAxisAlignment: CrossAxisAlignment.center,
84 | children: [
85 | IconButton.outlined(
86 | onPressed: () {
87 | launchUrlString(ConstantHelp.authorSite);
88 | },
89 | icon: const Icon(Icons.person_rounded),
90 | iconSize: 36,
91 | ),
92 | const SizedBox(height: 6),
93 | Text('contactAuthor'.tr),
94 | ],
95 | ),
96 | ],
97 | ),
98 | ),
99 | const Spacer(),
100 | Text(
101 | 'Released under the GUN GPL-3.0 License\n'
102 | 'Copyright © 2022-${DateTime.now().year} liuyuxin',
103 | textAlign: TextAlign.center,
104 | style: const TextStyle(fontSize: 14),
105 | ),
106 | const SizedBox(height: 4),
107 | ],
108 | ),
109 | ),
110 | ),
111 | );
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/lib/ui/views/setting/data_manage/data_manage_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/helpers/opml_helper.dart';
4 |
5 | class DataManageView extends StatelessWidget {
6 | const DataManageView({super.key});
7 |
8 | @override
9 | Widget build(BuildContext context) {
10 | return Scaffold(
11 | body: CustomScrollView(
12 | physics: const BouncingScrollPhysics(),
13 | slivers: [
14 | SliverAppBar.large(
15 | title: Text('dataManage'.tr),
16 | ),
17 | SliverList.list(
18 | children: [
19 | ListTile(
20 | leading: const Icon(Icons.download_rounded),
21 | title: Text('importOpml'.tr),
22 | subtitle: Text(
23 | 'importOpmlInfo'.tr,
24 | style: TextStyle(color: Get.theme.colorScheme.outline),
25 | ),
26 | onTap: OpmlHelper.importOpml,
27 | ),
28 | ListTile(
29 | leading: const Icon(Icons.publish_rounded),
30 | title: Text('exportOpml'.tr),
31 | subtitle: Text(
32 | 'exportOpmlInfo'.tr,
33 | style: TextStyle(color: Get.theme.colorScheme.outline),
34 | ),
35 | onTap: OpmlHelper.exportOpml,
36 | ),
37 | ],
38 | ),
39 | ],
40 | ),
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/lib/ui/views/setting/display/display_setting_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/ui/viewmodels/settings/display/display_setting_controller.dart';
4 |
5 | class DisplaySettingView extends StatelessWidget {
6 | const DisplaySettingView({super.key});
7 |
8 | @override
9 | Widget build(BuildContext context) {
10 | final c = Get.put(DisplaySettingController());
11 | return Scaffold(
12 | body: CustomScrollView(
13 | physics: const BouncingScrollPhysics(),
14 | slivers: [
15 | SliverAppBar.large(
16 | title: Text('displaySetting'.tr),
17 | ),
18 | SliverList.list(
19 | children: [
20 | ListTile(
21 | leading: const Icon(Icons.dark_mode_rounded),
22 | title: Text('darkMode'.tr),
23 | subtitle: Obx(() => Text(
24 | [
25 | 'followSystem'.tr,
26 | 'close'.tr,
27 | 'open'.tr,
28 | ][c.themeMode.value],
29 | style: TextStyle(color: Get.theme.colorScheme.outline),
30 | )),
31 | trailing: Row(
32 | mainAxisSize: MainAxisSize.min,
33 | mainAxisAlignment: MainAxisAlignment.center,
34 | crossAxisAlignment: CrossAxisAlignment.center,
35 | children: [
36 | const VerticalDivider(
37 | indent: 12,
38 | endIndent: 12,
39 | width: 24,
40 | ),
41 | Obx(() => Switch(
42 | value: c.themeMode.value == 2,
43 | onChanged: (value) =>
44 | c.changeThemeMode(value ? 2 : 1),
45 | )),
46 | ],
47 | ),
48 | onTap: () {
49 | int mode = c.themeMode.value;
50 | Get.dialog(AlertDialog(
51 | icon: const Icon(Icons.dark_mode_rounded),
52 | title: Text('darkMode'.tr),
53 | content: StatefulBuilder(
54 | builder: (context, setState) {
55 | return Column(
56 | mainAxisSize: MainAxisSize.min,
57 | mainAxisAlignment: MainAxisAlignment.center,
58 | crossAxisAlignment: CrossAxisAlignment.center,
59 | children: [
60 | for (int i = 0; i < 3; i++)
61 | RadioListTile(
62 | value: i,
63 | groupValue: mode,
64 | title: Text(
65 | [
66 | 'followSystem'.tr,
67 | 'close'.tr,
68 | 'open'.tr,
69 | ][i],
70 | ),
71 | onChanged: (value) {
72 | if (value != null && value != mode) {
73 | setState(() {
74 | mode = value;
75 | });
76 | }
77 | },
78 | visualDensity: VisualDensity.compact,
79 | shape: RoundedRectangleBorder(
80 | borderRadius: BorderRadius.circular(80),
81 | ),
82 | ),
83 | ],
84 | );
85 | },
86 | ),
87 | actions: [
88 | TextButton(
89 | onPressed: () {
90 | Get.back();
91 | },
92 | child: Text('cancel'.tr),
93 | ),
94 | TextButton(
95 | onPressed: () {
96 | c.changeThemeMode(mode);
97 | Get.back();
98 | },
99 | child: Text('confirm'.tr),
100 | ),
101 | ],
102 | ));
103 | },
104 | ),
105 | Obx(() => SwitchListTile(
106 | value: c.enableDynamicColor.value,
107 | secondary: const Icon(Icons.color_lens_rounded),
108 | title: Text('dynamicColor'.tr),
109 | subtitle: Text(
110 | 'dynamicColorInfo'.tr,
111 | style: TextStyle(color: Get.theme.colorScheme.outline),
112 | ),
113 | onChanged: (value) => c.changeEnableDynamicColor(value),
114 | )),
115 | ListTile(
116 | leading: const Icon(Icons.font_download_rounded),
117 | title: Text('globalFont'.tr),
118 | subtitle: Obx(() => Text(
119 | c.globalFont.value == 'system'
120 | ? 'defaultFont'.tr
121 | : c.globalFont.value.split('.').first,
122 | style: TextStyle(color: Get.theme.colorScheme.outline),
123 | )),
124 | onTap: () async {
125 | String selestedFont = c.globalFont.value;
126 | await c.refreshFontList();
127 | Get.dialog(
128 | StatefulBuilder(builder: (context, setState) {
129 | return AlertDialog(
130 | icon: const Icon(Icons.font_download_rounded),
131 | title: Text('globalFont'.tr),
132 | content: Column(
133 | mainAxisSize: MainAxisSize.min,
134 | mainAxisAlignment: MainAxisAlignment.center,
135 | crossAxisAlignment: CrossAxisAlignment.center,
136 | children: [
137 | for (String font in c.fontList)
138 | RadioListTile(
139 | value: font,
140 | groupValue: selestedFont,
141 | title: Text(
142 | font == 'system'
143 | ? 'defaultFont'.tr
144 | : font.split('.').first,
145 | style: TextStyle(fontFamily: font),
146 | ),
147 | onChanged: (value) {
148 | if (value != null &&
149 | value != selestedFont) {
150 | setState(() {
151 | selestedFont = value;
152 | });
153 | }
154 | },
155 | secondary: font == 'system'
156 | ? null
157 | : IconButton(
158 | icon: const Icon(
159 | Icons.remove_circle_rounded),
160 | onPressed: () async {
161 | await c.deleteFont(font);
162 | setState(() {});
163 | },
164 | ),
165 | visualDensity: VisualDensity.compact,
166 | shape: RoundedRectangleBorder(
167 | borderRadius: BorderRadius.circular(80),
168 | ),
169 | ),
170 | ],
171 | ),
172 | actions: [
173 | Row(
174 | mainAxisSize: MainAxisSize.min,
175 | mainAxisAlignment: MainAxisAlignment.center,
176 | crossAxisAlignment: CrossAxisAlignment.center,
177 | children: [
178 | TextButton(
179 | onPressed: () {
180 | c.importFont().then((_) {
181 | setState(() {});
182 | });
183 | },
184 | child: Text('importFont'.tr),
185 | ),
186 | const Spacer(),
187 | TextButton(
188 | onPressed: () {
189 | Get.back();
190 | },
191 | child: Text('cancel'.tr),
192 | ),
193 | TextButton(
194 | onPressed: () {
195 | c.changeGlobalFont(selestedFont);
196 | Get.back();
197 | },
198 | child: Text('confirm'.tr),
199 | ),
200 | ],
201 | ),
202 | ]);
203 | }),
204 | );
205 | },
206 | ),
207 | ListTile(
208 | leading: const Icon(Icons.animation_rounded),
209 | title: Text('animationEffect'.tr),
210 | subtitle: Obx(() => Text(
211 | {
212 | 'cupertino': 'smoothScrolling'.tr,
213 | 'fade': 'fadeInAndOut'.tr,
214 | }[c.transition.value] ??
215 | 'smoothScrolling'.tr,
216 | style: TextStyle(color: Get.theme.colorScheme.outline),
217 | )),
218 | onTap: () {
219 | String selectedTransition = c.transition.value;
220 | final Map transitions = {
221 | 'cupertino': 'smoothScrolling'.tr,
222 | 'fade': 'fadeInAndOut'.tr,
223 | };
224 | Get.dialog(
225 | StatefulBuilder(
226 | builder: (context, setState) {
227 | return AlertDialog(
228 | icon: const Icon(Icons.animation_rounded),
229 | title: Text('animationEffect'.tr),
230 | content: Column(
231 | mainAxisSize: MainAxisSize.min,
232 | mainAxisAlignment: MainAxisAlignment.center,
233 | crossAxisAlignment: CrossAxisAlignment.start,
234 | children: [
235 | for (String key in transitions.keys)
236 | RadioListTile(
237 | value: key,
238 | groupValue: selectedTransition,
239 | title: Text(transitions[key] ?? ''),
240 | onChanged: (value) {
241 | if (value != null &&
242 | value != selectedTransition) {
243 | setState(() {
244 | selectedTransition = value;
245 | });
246 | }
247 | },
248 | visualDensity: VisualDensity.compact,
249 | shape: RoundedRectangleBorder(
250 | borderRadius: BorderRadius.circular(80),
251 | ),
252 | ),
253 | const SizedBox(height: 8),
254 | Text('* ${'animationEffectInfo'.tr}'),
255 | ],
256 | ),
257 | actions: [
258 | TextButton(
259 | onPressed: () {
260 | Get.back();
261 | },
262 | child: Text('cancel'.tr),
263 | ),
264 | TextButton(
265 | onPressed: () {
266 | c.changeTransition(selectedTransition);
267 | Get.back();
268 | },
269 | child: Text('confirm'.tr),
270 | ),
271 | ],
272 | );
273 | },
274 | ),
275 | );
276 | },
277 | ),
278 | ListTile(
279 | leading: const Icon(Icons.text_fields_rounded),
280 | title: Text('textScale'.tr),
281 | subtitle: Obx(() => Text(
282 | 'textScaleFactor'.tr +
283 | c.textScaleFactor.value.toStringAsFixed(1),
284 | style: TextStyle(color: Get.theme.colorScheme.outline),
285 | )),
286 | onTap: () {
287 | double factor = c.textScaleFactor.value;
288 | Get.dialog(AlertDialog(
289 | icon: const Icon(Icons.text_fields_rounded),
290 | title: Text('textScale'.tr),
291 | content: StatefulBuilder(
292 | builder: (context, setState) {
293 | return SizedBox(
294 | height: 64,
295 | child: Slider(
296 | value: factor,
297 | min: 0.8,
298 | max: 2.0,
299 | divisions: 12,
300 | label: factor.toStringAsFixed(1),
301 | onChanged: (value) {
302 | setState(() {
303 | factor = value;
304 | });
305 | },
306 | ),
307 | );
308 | },
309 | ),
310 | actions: [
311 | TextButton(
312 | onPressed: () {
313 | Get.back();
314 | },
315 | child: Text('cancel'.tr),
316 | ),
317 | TextButton(
318 | onPressed: () {
319 | c.changeTextScaleFactor(factor);
320 | Get.back();
321 | },
322 | child: Text('confirm'.tr),
323 | ),
324 | ],
325 | ));
326 | }),
327 | ListTile(
328 | leading: const Icon(Icons.language_rounded),
329 | title: Text('language'.tr),
330 | subtitle: Obx(() => Text(
331 | [
332 | 'systemLanguage'.tr,
333 | 'zh_CN'.tr,
334 | 'en_US'.tr,
335 | ][c.languageList.indexOf(c.language.value)],
336 | style: TextStyle(color: Get.theme.colorScheme.outline),
337 | )),
338 | onTap: () {
339 | String selectedLanguage = c.language.value;
340 | Get.dialog(AlertDialog(
341 | icon: const Icon(Icons.language_rounded),
342 | title: Text('language'.tr),
343 | content: StatefulBuilder(
344 | builder: (context, setState) {
345 | return Column(
346 | mainAxisSize: MainAxisSize.min,
347 | mainAxisAlignment: MainAxisAlignment.center,
348 | crossAxisAlignment: CrossAxisAlignment.center,
349 | children: [
350 | for (String language in c.languageList)
351 | RadioListTile(
352 | value: language,
353 | groupValue: selectedLanguage,
354 | title: Text(
355 | [
356 | 'systemLanguage'.tr,
357 | 'zh_CN'.tr,
358 | 'en_US'.tr,
359 | ][c.languageList.indexOf(language)],
360 | ),
361 | onChanged: (value) {
362 | if (value != null &&
363 | value != selectedLanguage) {
364 | setState(() {
365 | selectedLanguage = value;
366 | });
367 | }
368 | },
369 | visualDensity: VisualDensity.compact,
370 | shape: RoundedRectangleBorder(
371 | borderRadius: BorderRadius.circular(80),
372 | ),
373 | ),
374 | ],
375 | );
376 | },
377 | ),
378 | actions: [
379 | TextButton(
380 | onPressed: () {
381 | Get.back();
382 | },
383 | child: Text('cancel'.tr),
384 | ),
385 | TextButton(
386 | onPressed: () {
387 | c.changeLanguage(selectedLanguage);
388 | Get.back();
389 | },
390 | child: Text('confirm'.tr),
391 | ),
392 | ],
393 | ));
394 | },
395 | ),
396 | ],
397 | ),
398 | ],
399 | ),
400 | );
401 | }
402 | }
403 |
--------------------------------------------------------------------------------
/lib/ui/views/setting/read/read_setting_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/ui/viewmodels/settings/read/read_controller.dart';
4 |
5 | class ReadSettingView extends StatelessWidget {
6 | const ReadSettingView({super.key});
7 |
8 | @override
9 | Widget build(BuildContext context) {
10 | final c = Get.put(ReadController());
11 | final Map alignMap = {
12 | 'left': 'leftAlign'.tr,
13 | 'right': 'rightAlign'.tr,
14 | 'center': 'centerAlign'.tr,
15 | 'justify': 'justifyAlign'.tr,
16 | };
17 | return Scaffold(
18 | body: CustomScrollView(
19 | physics: const BouncingScrollPhysics(),
20 | slivers: [
21 | SliverAppBar.large(
22 | title: Text('readSetting'.tr),
23 | ),
24 | SliverList.list(
25 | children: [
26 | ListTile(
27 | leading: const Icon(Icons.text_fields_rounded),
28 | title: Text('fontSize'.tr),
29 | subtitle: Obx(() => Text('${c.fontSize.value}')),
30 | onTap: () {
31 | int size = c.fontSize.value;
32 | Get.dialog(AlertDialog(
33 | icon: const Icon(Icons.text_fields_rounded),
34 | title: Text('fontSize'.tr),
35 | content: StatefulBuilder(
36 | builder: (context, setState) {
37 | return SizedBox(
38 | height: 64,
39 | child: Slider(
40 | value: size.toDouble(),
41 | min: 12,
42 | max: 24,
43 | divisions: 12,
44 | label: size.toInt().toString(),
45 | onChanged: (value) {
46 | setState(() {
47 | size = value.toInt();
48 | });
49 | },
50 | ),
51 | );
52 | },
53 | ),
54 | actions: [
55 | TextButton(
56 | onPressed: () {
57 | Get.back();
58 | },
59 | child: Text('cancel'.tr),
60 | ),
61 | TextButton(
62 | onPressed: () {
63 | c.changeFontSize(size);
64 | Get.back();
65 | },
66 | child: Text('confirm'.tr),
67 | ),
68 | ],
69 | ));
70 | },
71 | ),
72 | ListTile(
73 | leading: const Icon(Icons.line_weight_rounded),
74 | title: Text('lineHeight'.tr),
75 | subtitle:
76 | Obx(() => Text(c.lineHeight.value.toStringAsFixed(1))),
77 | onTap: () {
78 | double height = c.lineHeight.value;
79 | Get.dialog(AlertDialog(
80 | icon: const Icon(Icons.line_weight_rounded),
81 | title: Text('lineHeight'.tr),
82 | content: StatefulBuilder(
83 | builder: (context, setState) {
84 | return SizedBox(
85 | height: 64,
86 | child: Slider(
87 | value: height,
88 | min: 1.0,
89 | max: 2.0,
90 | divisions: 10,
91 | label: height.toStringAsFixed(1),
92 | onChanged: (value) {
93 | setState(() {
94 | height = value;
95 | });
96 | },
97 | ),
98 | );
99 | },
100 | ),
101 | actions: [
102 | TextButton(
103 | onPressed: () {
104 | Get.back();
105 | },
106 | child: Text('cancel'.tr),
107 | ),
108 | TextButton(
109 | onPressed: () {
110 | c.changeLineHeight(height);
111 | Get.back();
112 | },
113 | child: Text('confirm'.tr),
114 | ),
115 | ],
116 | ));
117 | },
118 | ),
119 | ListTile(
120 | leading: const Icon(Icons.padding_rounded),
121 | title: Text('pagePadding'.tr),
122 | subtitle: Obx(() => Text('${c.pagePadding.value}')),
123 | onTap: () {
124 | int padding = c.pagePadding.value;
125 | Get.dialog(AlertDialog(
126 | icon: const Icon(Icons.padding_rounded),
127 | title: Text('pagePadding'.tr),
128 | content: StatefulBuilder(
129 | builder: (context, setState) {
130 | return SizedBox(
131 | height: 64,
132 | child: Slider(
133 | value: padding.toDouble(),
134 | min: 0,
135 | max: 40,
136 | divisions: 10,
137 | label: padding.toString(),
138 | onChanged: (value) {
139 | setState(() {
140 | padding = value.toInt();
141 | });
142 | },
143 | ),
144 | );
145 | },
146 | ),
147 | actions: [
148 | TextButton(
149 | onPressed: () {
150 | Get.back();
151 | },
152 | child: Text('cancel'.tr),
153 | ),
154 | TextButton(
155 | onPressed: () {
156 | c.changePagePadding(padding);
157 | Get.back();
158 | },
159 | child: Text('confirm'.tr),
160 | ),
161 | ],
162 | ));
163 | },
164 | ),
165 | ListTile(
166 | leading: const Icon(Icons.format_align_left_rounded),
167 | title: Text('textAlign'.tr),
168 | subtitle: Obx(
169 | () => Text(alignMap[c.textAlign.value] ?? 'leftAlign'.tr)),
170 | onTap: () {
171 | String align = c.textAlign.value;
172 | Get.dialog(AlertDialog(
173 | icon: const Icon(Icons.format_align_left_rounded),
174 | title: Text('textAlign'.tr),
175 | content: StatefulBuilder(
176 | builder: (context, setState) {
177 | return Column(
178 | mainAxisSize: MainAxisSize.min,
179 | mainAxisAlignment: MainAxisAlignment.center,
180 | crossAxisAlignment: CrossAxisAlignment.center,
181 | children: [
182 | for (String i in alignMap.keys)
183 | RadioListTile(
184 | value: i,
185 | groupValue: align,
186 | title: Text(
187 | alignMap[i] ?? '',
188 | ),
189 | onChanged: (value) {
190 | if (value != null) {
191 | setState(() {
192 | align = value;
193 | });
194 | }
195 | },
196 | visualDensity: VisualDensity.compact,
197 | shape: RoundedRectangleBorder(
198 | borderRadius: BorderRadius.circular(80),
199 | ),
200 | ),
201 | ],
202 | );
203 | },
204 | ),
205 | actions: [
206 | TextButton(
207 | onPressed: () {
208 | Get.back();
209 | },
210 | child: Text('cancel'.tr),
211 | ),
212 | TextButton(
213 | onPressed: () {
214 | c.changeTextAlign(align);
215 | Get.back();
216 | },
217 | child: Text('confirm'.tr),
218 | ),
219 | ],
220 | ));
221 | },
222 | ),
223 | ],
224 | ),
225 | ],
226 | ),
227 | );
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/lib/ui/views/setting/resolve/resolve_setting_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/ui/viewmodels/settings/resolve/resolve_setting_controller.dart';
4 |
5 | class ResolveSettingView extends StatelessWidget {
6 | const ResolveSettingView({super.key});
7 |
8 | @override
9 | Widget build(BuildContext context) {
10 | final c = Get.put(ResolveSettingController());
11 | return Scaffold(
12 | body: CustomScrollView(
13 | physics: const BouncingScrollPhysics(),
14 | slivers: [
15 | SliverAppBar.large(
16 | title: Text('resolveSetting'.tr),
17 | ),
18 | SliverList.list(
19 | children: [
20 | Obx(() => SwitchListTile(
21 | value: c.refreshOnStartup.value,
22 | onChanged: c.changeRefreshOnStartup,
23 | title: Text('refreshOnStartup'.tr),
24 | subtitle: Text(
25 | 'refreshOnStartupInfo'.tr,
26 | style: TextStyle(color: Get.theme.colorScheme.outline),
27 | ),
28 | secondary: const Icon(Icons.refresh_rounded),
29 | )),
30 | ListTile(
31 | leading: const Icon(Icons.block_rounded),
32 | title: Text('blockWords'.tr),
33 | subtitle: Text(
34 | 'blockWordsInfo'.tr,
35 | style: TextStyle(color: Get.theme.colorScheme.outline),
36 | ),
37 | onTap: () {
38 | List words = c.blockWords;
39 | Get.dialog(
40 | StatefulBuilder(
41 | builder: (context, setState) {
42 | return AlertDialog(
43 | scrollable: true,
44 | icon: const Icon(Icons.block_rounded),
45 | title: Text('blockWords'.tr),
46 | content: Column(
47 | children: [
48 | for (int i = 0; i < words.length; i++)
49 | ListTile(
50 | title: Text(words[i]),
51 | trailing: IconButton(
52 | icon: const Icon(
53 | Icons.do_not_disturb_on_rounded),
54 | onPressed: () => setState(() {
55 | words.removeAt(i);
56 | }),
57 | ),
58 | ),
59 | ],
60 | ),
61 | actions: [
62 | Row(
63 | mainAxisSize: MainAxisSize.min,
64 | children: [
65 | TextButton(
66 | child: Text('add'.tr),
67 | onPressed: () {
68 | final controller = TextEditingController();
69 | Get.dialog(
70 | AlertDialog(
71 | icon: const Icon(Icons.add_rounded),
72 | title: Text('addBlockWord'.tr),
73 | content: TextField(
74 | controller: controller,
75 | autofocus: true,
76 | decoration: InputDecoration(
77 | border:
78 | const UnderlineInputBorder(),
79 | hintText: 'addBlockWord'.tr,
80 | ),
81 | ),
82 | actions: [
83 | TextButton(
84 | child: Text('cancel'.tr),
85 | onPressed: () {
86 | Get.back();
87 | },
88 | ),
89 | TextButton(
90 | child: Text('confirm'.tr),
91 | onPressed: () {
92 | if (controller.text.isNotEmpty) {
93 | setState(() {
94 | words.add(controller.text);
95 | });
96 | }
97 | Get.back();
98 | },
99 | ),
100 | ],
101 | ),
102 | );
103 | },
104 | ),
105 | const Spacer(),
106 | TextButton(
107 | child: Text('cancel'.tr),
108 | onPressed: () {
109 | Get.back();
110 | },
111 | ),
112 | TextButton(
113 | child: Text('confirm'.tr),
114 | onPressed: () {
115 | c.changeBlockWords(words);
116 | Get.back();
117 | },
118 | ),
119 | ],
120 | ),
121 | ],
122 | );
123 | },
124 | ),
125 | );
126 | },
127 | ),
128 | Obx(
129 | () => SwitchListTile(
130 | value: c.useProxy.value,
131 | onChanged: c.changeUseProxy,
132 | title: Text('useProxy'.tr),
133 | subtitle: Text(
134 | 'useProxyInfo'.tr,
135 | style: TextStyle(color: Get.theme.colorScheme.outline),
136 | ),
137 | secondary: const Icon(Icons.public_rounded),
138 | ),
139 | ),
140 | ListTile(
141 | leading: const Icon(Icons.link_rounded),
142 | title: Text('proxyAddress'.tr),
143 | subtitle: Obx(() => Text(
144 | c.proxyAddress.value.isEmpty
145 | ? 'notSet'.tr
146 | : c.proxyAddress.value,
147 | style: TextStyle(color: Get.theme.colorScheme.outline),
148 | )),
149 | onTap: () {
150 | final controller =
151 | TextEditingController(text: c.proxyAddress.value);
152 | Get.dialog(
153 | AlertDialog(
154 | icon: const Icon(Icons.link_rounded),
155 | title: Text('proxyAddress'.tr),
156 | content: TextField(
157 | controller: controller,
158 | autofocus: true,
159 | decoration: InputDecoration(
160 | border: const UnderlineInputBorder(),
161 | hintText: 'proxyAddress'.tr,
162 | ),
163 | ),
164 | actions: [
165 | TextButton(
166 | child: Text('cancel'.tr),
167 | onPressed: () {
168 | Get.back();
169 | },
170 | ),
171 | TextButton(
172 | child: Text('confirm'.tr),
173 | onPressed: () {
174 | c.changeProxyAddress(controller.text);
175 | Get.back();
176 | },
177 | ),
178 | ],
179 | ),
180 | );
181 | },
182 | ),
183 | ListTile(
184 | leading: const Icon(Icons.tag_rounded),
185 | title: Text('proxyPort'.tr),
186 | subtitle: Obx(() => Text(
187 | c.proxyPort.value.toString().isEmpty
188 | ? 'notSet'.tr
189 | : c.proxyPort.value.toString(),
190 | style: TextStyle(color: Get.theme.colorScheme.outline),
191 | )),
192 | onTap: () {
193 | final controller =
194 | TextEditingController(text: c.proxyPort.value.toString());
195 | Get.dialog(
196 | AlertDialog(
197 | icon: const Icon(Icons.tag_rounded),
198 | title: Text('proxyPort'.tr),
199 | content: TextField(
200 | controller: controller,
201 | autofocus: true,
202 | decoration: InputDecoration(
203 | border: const UnderlineInputBorder(),
204 | hintText: 'proxyPort'.tr,
205 | ),
206 | ),
207 | actions: [
208 | TextButton(
209 | child: Text('cancel'.tr),
210 | onPressed: () {
211 | Get.back();
212 | },
213 | ),
214 | TextButton(
215 | child: Text('confirm'.tr),
216 | onPressed: () {
217 | c.changeProxyPort(controller.text);
218 | Get.back();
219 | },
220 | ),
221 | ],
222 | ),
223 | );
224 | },
225 | ),
226 | ],
227 | ),
228 | ],
229 | ),
230 | );
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/lib/ui/views/setting/setting_view.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 |
4 | class SettingView extends StatelessWidget {
5 | const SettingView({super.key});
6 |
7 | @override
8 | Widget build(BuildContext context) {
9 | return Scaffold(
10 | body: CustomScrollView(
11 | physics: const BouncingScrollPhysics(),
12 | slivers: [
13 | SliverAppBar.large(
14 | title: Text('moreSetting'.tr),
15 | ),
16 | SliverList.list(
17 | children: [
18 | ListTile(
19 | leading: const Icon(Icons.color_lens_rounded),
20 | title: Text('displaySetting'.tr),
21 | subtitle: Text(
22 | 'displaySettingInfo'.tr,
23 | style: TextStyle(color: Get.theme.colorScheme.outline),
24 | ),
25 | onTap: () => Get.toNamed('/setting/display'),
26 | ),
27 | ListTile(
28 | leading: const Icon(Icons.article_outlined),
29 | title: Text('readSetting'.tr),
30 | subtitle: Text(
31 | 'readSettingInfo'.tr,
32 | style: TextStyle(color: Get.theme.colorScheme.outline),
33 | ),
34 | onTap: () => Get.toNamed('/setting/read'),
35 | ),
36 | ListTile(
37 | leading: const Icon(Icons.travel_explore_rounded),
38 | title: Text('resolveSetting'.tr),
39 | subtitle: Text(
40 | 'resolveSettingInfo'.tr,
41 | style: TextStyle(color: Get.theme.colorScheme.outline),
42 | ),
43 | onTap: () => Get.toNamed('/setting/resolve'),
44 | ),
45 | ListTile(
46 | leading: const Icon(Icons.data_usage_rounded),
47 | title: Text('dataManage'.tr),
48 | subtitle: Text(
49 | 'dataManageInfo'.tr,
50 | style: TextStyle(color: Get.theme.colorScheme.outline),
51 | ),
52 | onTap: () => Get.toNamed('/setting/data_manage'),
53 | ),
54 | ListTile(
55 | leading: const Icon(Icons.android_outlined),
56 | title: Text('aboutApp'.tr),
57 | subtitle: Text(
58 | 'aboutAppInfo'.tr,
59 | style: TextStyle(color: Get.theme.colorScheme.outline),
60 | ),
61 | onTap: () {
62 | Get.toNamed('/setting/about');
63 | },
64 | ),
65 | ],
66 | ),
67 | ],
68 | ),
69 | );
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/lib/ui/widgets/feed_panel.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/models/category.dart';
4 | import 'package:meread/models/feed.dart';
5 |
6 | class FeedPanel extends StatefulWidget {
7 | final Category category;
8 | final Function() categoryOnTap;
9 | final Function(Feed feed) feedOnTap;
10 |
11 | const FeedPanel({
12 | super.key,
13 | required this.category,
14 | required this.categoryOnTap,
15 | required this.feedOnTap,
16 | });
17 |
18 | @override
19 | State createState() => _FeedPanelState();
20 | }
21 |
22 | class _FeedPanelState extends State {
23 | bool _expanded = false;
24 | @override
25 | Widget build(BuildContext context) {
26 | return SizedBox(
27 | child: Column(
28 | children: [
29 | ListTile(
30 | leading: IconButton(
31 | icon: Icon(_expanded ? Icons.expand_less : Icons.expand_more),
32 | onPressed: () {
33 | setState(() {
34 | _expanded = !_expanded;
35 | });
36 | },
37 | ),
38 | title: Text(
39 | widget.category.name,
40 | style: const TextStyle(fontSize: 16),
41 | ),
42 | contentPadding: const EdgeInsets.all(0),
43 | onTap: () => widget.categoryOnTap(),
44 | dense: true,
45 | ),
46 | AnimatedSwitcher(
47 | duration: const Duration(milliseconds: 100),
48 | transitionBuilder: (child, animation) {
49 | return SizeTransition(
50 | sizeFactor: animation,
51 | child: child,
52 | );
53 | },
54 | child: _expanded
55 | ? Container(
56 | margin: const EdgeInsets.symmetric(horizontal: 12),
57 | decoration: BoxDecoration(
58 | borderRadius: BorderRadius.circular(18),
59 | color: Get.theme.colorScheme.secondaryContainer
60 | .withAlpha(100),
61 | ),
62 | child: Column(
63 | children: widget.category.feeds
64 | .map((feed) => ListTile(
65 | title: Text(feed.title),
66 | dense: true,
67 | visualDensity: VisualDensity.compact,
68 | onTap: () => widget.feedOnTap(feed),
69 | shape: RoundedRectangleBorder(
70 | borderRadius: BorderRadius.circular(24),
71 | ),
72 | ))
73 | .toList(),
74 | ),
75 | )
76 | : const SizedBox.shrink(),
77 | ),
78 | ],
79 | ),
80 | );
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/lib/ui/widgets/post_card.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:get/get.dart';
3 | import 'package:meread/models/post.dart';
4 |
5 | class PostCard extends StatelessWidget {
6 | final Post post;
7 | const PostCard({super.key, required this.post});
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return Container(
12 | padding: const EdgeInsets.all(12),
13 | decoration: BoxDecoration(
14 | borderRadius: BorderRadius.circular(12),
15 | color: Get.theme.colorScheme.secondaryContainer
16 | .withAlpha(post.read ? 20 : 60),
17 | ),
18 | child: Column(
19 | crossAxisAlignment: CrossAxisAlignment.start,
20 | mainAxisAlignment: MainAxisAlignment.center,
21 | children: [
22 | Text(
23 | post.title,
24 | style: TextStyle(
25 | fontSize: 16,
26 | color: post.read
27 | ? Theme.of(context).colorScheme.outline.withAlpha(150)
28 | : null,
29 | ),
30 | ),
31 | const SizedBox(height: 4),
32 | Text(
33 | post.content,
34 | maxLines: 2,
35 | overflow: TextOverflow.ellipsis,
36 | style: TextStyle(
37 | fontSize: 13,
38 | color: Theme.of(context).colorScheme.outline.withAlpha(
39 | post.read ? 120 : 255,
40 | ),
41 | ),
42 | ),
43 | const SizedBox(height: 4),
44 | Row(
45 | mainAxisAlignment: MainAxisAlignment.center,
46 | crossAxisAlignment: CrossAxisAlignment.center,
47 | children: [
48 | Text(
49 | post.feed.value?.title ?? '',
50 | style: TextStyle(
51 | fontSize: 12,
52 | color: Theme.of(context).colorScheme.secondary.withAlpha(
53 | post.read ? 120 : 255,
54 | ),
55 | ),
56 | ),
57 | const Spacer(),
58 | if (post.favorite)
59 | Container(
60 | width: 24,
61 | decoration: BoxDecoration(
62 | borderRadius: BorderRadius.circular(12),
63 | color: Theme.of(context).colorScheme.primaryContainer,
64 | ),
65 | child: Icon(
66 | Icons.bookmark_rounded,
67 | size: 16,
68 | color: Theme.of(context).colorScheme.secondary.withAlpha(
69 | post.read ? 120 : 255,
70 | ),
71 | ),
72 | ),
73 | const SizedBox(width: 8),
74 | Text(
75 | post.pubDate.toLocal().toString().substring(0, 16),
76 | style: TextStyle(
77 | fontSize: 12,
78 | color: Theme.of(context).colorScheme.secondary.withAlpha(
79 | post.read ? 120 : 255,
80 | ),
81 | ),
82 | ),
83 | ],
84 | ),
85 | ],
86 | ),
87 | );
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/pubspec.yaml:
--------------------------------------------------------------------------------
1 | name: meread
2 | description: MeRead
3 | publish_to: "none"
4 |
5 | version: 0.6.1+23
6 |
7 | environment:
8 | sdk: ">=2.18.2 <3.0.0"
9 |
10 | dependencies:
11 | dart_rss: ^3.0.1
12 | dio: ^5.4.1
13 | dynamic_color: ^1.6.9
14 | file_picker: ^8.0.3
15 | flutter:
16 | sdk: flutter
17 | flutter_inappwebview: ^6.0.0
18 | flutter_localizations:
19 | sdk: flutter
20 | flutter_swipe_action_cell: ^3.1.3
21 | flutter_widget_from_html: ^0.15.0
22 | fluttertoast: ^8.2.5
23 | get: ^4.6.6
24 | html: ^0.15.4
25 | html_main_element: ^2.1.0
26 | intl: ^0.19.0
27 | isar: ^3.1.0+1
28 | isar_flutter_libs: ^3.1.0+1
29 | logger: ^2.0.2+1
30 | opml: ^0.4.0
31 | path_provider: ^2.1.2
32 | provider: ^6.1.1
33 | share_plus: ^10.0.0
34 | shared_preferences: ^2.2.2
35 | url_launcher: ^6.2.4
36 |
37 | dev_dependencies:
38 | flutter_test:
39 | sdk: flutter
40 | isar_generator: ^3.1.0+1
41 | build_runner: ^2.4.8
42 | flutter_lints: ^4.0.0
43 |
44 | flutter:
45 | uses-material-design: true
46 | assets:
47 | - assets/meread.png
48 |
--------------------------------------------------------------------------------
/test/widget_test.dart:
--------------------------------------------------------------------------------
1 | void main() {}
2 |
--------------------------------------------------------------------------------