v0.1.6版本

This commit is contained in:
DelLevin-Home
2026-03-12 23:33:41 +08:00
parent 235b42c620
commit e181ebf9c6
36 changed files with 5450 additions and 2053 deletions

661
LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.

View File

@@ -3,31 +3,13 @@
## 项目简介
MookNote 是一款用于记录观影、阅读和笔记的 Android 软件,基于 Flutter 框架开发。
## 功能特性
## 功能
- 影视增删改查
- 书籍增删改查
- 笔记增删改查
- 影评增删改查
- 书评增删改
### 主界面
- **顶部导航栏**: 左侧菜单按钮 + 标题 + 搜索按钮
- **三个标签页**: 观影、阅读、笔记
- **底部导航栏**: 主页、新增、我的
### 观影模块
- 三个状态筛选:已看、想看、在看
- 显示影片标题、年份、评分、观看日期、笔记
- 支持添加和管理观影记录
### 阅读模块
- 三个状态筛选:读完、在读、准备读
- 显示书籍标题、作者、评分、阅读日期、笔记
- 支持添加和管理书籍记录
### 笔记模块
- 显示所有笔记列表
- 支持标签分类
### 侧边菜单
- 个人记录简要
- 回顾影视/书籍
- 月份风格热力图
## 环境要求
@@ -87,6 +69,12 @@ flutter pub get
# 如果失败可以先添加代理再进行get
$env:HTTP_PROXY="http://127.0.0.1:10808"
$env:HTTPS_PROXY="http://127.0.0.1:10808"
flutter pub get
# 构建 APK
flutter build apk --release
# 或构建 App Bundle推荐用于 Google Play
flutter build appbundle --release
```
### 3. 连接设备或启动模拟器

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 332 KiB

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'pages/home_page.dart';
import 'utils/theme/app_theme.dart';
@@ -11,6 +12,15 @@ import 'package:flutter/widget_previews.dart';
void main() async {
// 确保 Flutter 绑定初始化完成
WidgetsFlutterBinding.ensureInitialized();
// 设置系统导航栏颜色与App主题一致
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
// 初始化用户偏好设置
await UserPrefs.init();
// 初始化数据库
@@ -59,7 +69,8 @@ class MyApp extends StatelessWidget {
}
/// 用于预览 MyApp 的 Widget
@Preview(name: "MookNote App Preview") // 添加 @Preview 注解
/// 添加 @Preview 注解
@Preview(name: "MookNote App Preview")
Widget previewMyApp() {
final appProvider = AppProvider();

View File

@@ -21,6 +21,7 @@ class Movie {
final String? summary; // 剧情简介
final double? rating; // 评分 1-10
final String status; // watched/want_to_watch/watching
final DateTime? watchDate; // 观看日期
final DateTime createdAt;
final DateTime updatedAt;
final bool isDeleted;
@@ -38,6 +39,7 @@ class Movie {
this.summary,
this.rating,
required this.status,
this.watchDate,
required this.createdAt,
required this.updatedAt,
this.isDeleted = false,
@@ -59,6 +61,9 @@ class Movie {
summary: json['summary'],
rating: json['rating']?.toDouble(),
status: json['status'] ?? 'want_to_watch',
watchDate: json['watch_date'] != null
? DateTime.parse(json['watch_date'])
: null,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: DateTime.now(),
@@ -83,6 +88,7 @@ class Movie {
'summary': summary,
'rating': rating,
'status': status,
'watch_date': watchDate?.toIso8601String(),
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'is_deleted': isDeleted ? 1 : 0,
@@ -133,6 +139,7 @@ class Movie {
Object? summary = _copyWithNull,
double? rating,
String? status,
DateTime? watchDate,
DateTime? createdAt,
DateTime? updatedAt,
bool? isDeleted,
@@ -150,6 +157,7 @@ class Movie {
summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?),
rating: rating ?? this.rating,
status: status ?? this.status,
watchDate: watchDate ?? this.watchDate,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isDeleted: isDeleted ?? this.isDeleted,
@@ -169,6 +177,8 @@ class Book {
final String? summary; // 书籍简介
final double? rating; // 评分 1-10
final String status; // read/reading/want_to_read
final String? isbn; // ISBN编号
final DateTime? publishDate; // 出版时间
final DateTime createdAt;
final DateTime updatedAt;
final bool isDeleted;
@@ -184,6 +194,8 @@ class Book {
this.summary,
this.rating,
required this.status,
this.isbn,
this.publishDate,
required this.createdAt,
required this.updatedAt,
this.isDeleted = false,
@@ -201,6 +213,10 @@ class Book {
summary: json['summary'],
rating: json['rating']?.toDouble(),
status: json['status'] ?? 'want_to_read',
isbn: json['isbn'],
publishDate: json['publish_date'] != null
? DateTime.parse(json['publish_date'])
: null,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: DateTime.now(),
@@ -223,6 +239,8 @@ class Book {
'summary': summary,
'rating': rating,
'status': status,
'isbn': isbn,
'publish_date': publishDate?.toIso8601String(),
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'is_deleted': isDeleted ? 1 : 0,
@@ -247,6 +265,8 @@ class Book {
Object? summary = _copyWithNull,
double? rating,
String? status,
Object? isbn = _copyWithNull,
DateTime? publishDate,
DateTime? createdAt,
DateTime? updatedAt,
bool? isDeleted,
@@ -262,6 +282,8 @@ class Book {
summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?),
rating: rating ?? this.rating,
status: status ?? this.status,
isbn: isbn is _CopyWithNullSentinel ? this.isbn : (isbn as String?),
publishDate: publishDate ?? this.publishDate,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isDeleted: isDeleted ?? this.isDeleted,

View File

@@ -60,13 +60,21 @@ class _BookDetailPageState extends State<BookDetailPage> {
// 作者信息
_buildAuthorsSection(book),
// 类型
if (book.genres.isNotEmpty)
_buildGenresSection(book),
// ISBN
if (book.isbn != null && book.isbn!.isNotEmpty)
_buildIsbnSection(book),
// 出版社
if (book.publisher != null && book.publisher!.isNotEmpty)
_buildPublisherSection(book),
// 类型
if (book.genres.isNotEmpty)
_buildGenresSection(book),
// 出版时间
if (book.publishDate != null)
_buildPublishDateSection(book),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
@@ -86,11 +94,41 @@ class _BookDetailPageState extends State<BookDetailPage> {
],
),
// 底部操作栏
bottomNavigationBar: _buildBottomBar(),
// 底部操作栏,编辑和删除在右上角
);
}
/// 构建右上角操作按钮
Widget _buildActionButton({
required IconData icon,
required VoidCallback onPressed,
required String tooltip,
Color color = const Color(0xFF666666),
}) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
icon,
color: color,
size: 22,
),
),
),
),
);
}
/// 构建顶部 AppBar
Widget _buildSliverAppBar(Book book) {
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
@@ -105,40 +143,30 @@ class _BookDetailPageState extends State<BookDetailPage> {
actions: [
// 下载封面按钮(仅当有封面时显示)
if (hasCover)
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.download_outlined, color: Color(0xFF666666)),
onPressed: () => _downloadCover(book),
tooltip: '下载封面',
),
_buildActionButton(
icon: Icons.download_outlined,
onPressed: () => _downloadCover(book),
tooltip: '下载封面',
),
// 清空封面按钮(仅当有封面时显示)
if (hasCover)
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.hide_image_outlined, color: Color(0xFF666666)),
onPressed: () => _showClearCoverDialog(book),
tooltip: '清空封面',
),
_buildActionButton(
icon: Icons.hide_image_outlined,
onPressed: () => _showClearCoverDialog(book),
tooltip: '清空封面',
),
// 编辑按钮
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.edit_outlined, color: Color(0xFF1A1A1A)),
onPressed: () => _navigateToEdit(context),
),
_buildActionButton(
icon: Icons.edit_outlined,
onPressed: () => _navigateToEdit(context),
tooltip: '编辑',
),
// 删除按钮
_buildActionButton(
icon: Icons.delete_outline,
color: Colors.red,
onPressed: () => _showDeleteDialog(context),
tooltip: '删除',
),
const SizedBox(width: 8),
],
@@ -256,36 +284,42 @@ class _BookDetailPageState extends State<BookDetailPage> {
/// 构建状态标签
Widget _buildStatusTag(Book book) {
String label;
Color color;
Color bgColor;
Color textColor;
switch (book.status) {
case 'read':
label = '已读';
color = const Color(0xFF1A1A1A);
bgColor = const Color(0xFF1A1A1A);
textColor = Colors.white;
break;
case 'reading':
label = '在读';
color = const Color(0xFF666666);
bgColor = const Color(0xFFF0F0F0);
textColor = const Color(0xFF666666);
break;
case 'want_to_read':
label = '想读';
color = const Color(0xFF999999);
bgColor = const Color(0xFFF5F5F5);
textColor = const Color(0xFF999999);
break;
default:
label = '未知';
color = const Color(0xFFCCCCCC);
bgColor = const Color(0xFFEEEEEE);
textColor = const Color(0xFFCCCCCC);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color,
color: bgColor,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
color: textColor,
fontWeight: FontWeight.w600,
),
),
);
@@ -294,109 +328,169 @@ class _BookDetailPageState extends State<BookDetailPage> {
/// 构建作者区域
Widget _buildAuthorsSection(Book book) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'作者',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
const SizedBox(
width: 64,
child: Text(
'作者',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: book.authors.map((author) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Text(
author,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1A1A1A),
),
),
);
}).toList(),
Expanded(
child: Text(
book.authors.join(''),
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建ISBN区域
Widget _buildIsbnSection(Book book) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 64,
child: Text(
'ISBN',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
Expanded(
child: Text(
book.isbn!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建出版社区域
Widget _buildPublisherSection(Book book) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'出版社',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
const SizedBox(
width: 64,
child: Text(
'出版社',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
const SizedBox(height: 8),
Text(
book.publisher!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
Expanded(
child: Text(
book.publisher!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建出版时间区域
Widget _buildPublishDateSection(Book book) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
width: 64,
child: Text(
'出版时间',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
Expanded(
child: Text(
'${book.publishDate!.year}${book.publishDate!.month.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建类型区域
Widget _buildGenresSection(Book book) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'类型',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
const SizedBox(
width: 64,
child: Text(
'类型',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: book.genres.map((genre) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Text(
genre,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
Expanded(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: book.genres.map((genre) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
),
),
);
}).toList(),
child: Text(
genre,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
),
);
}).toList(),
),
),
],
),
@@ -410,22 +504,41 @@ class _BookDetailPageState extends State<BookDetailPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'简介',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
),
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'简介',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 12),
Text(
book.summary!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.6,
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Text(
book.summary!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.8,
),
),
),
],
@@ -440,124 +553,122 @@ class _BookDetailPageState extends State<BookDetailPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'更多',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
),
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'更多',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 16),
// 书评入口
GestureDetector(
_buildExtraSectionItem(
icon: Icons.rate_review_outlined,
title: '书评',
subtitleFuture: context.read<AppProvider>().getBookReviewCount(book.id),
emptyText: '暂无书评',
unit: '条书评',
onTap: () => _navigateToReviews(book),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Row(
children: [
const Icon(
Icons.rate_review_outlined,
size: 24,
color: Color(0xFF666666),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'书评',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
FutureBuilder<int>(
future: context.read<AppProvider>().getBookReviewCount(book.id),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
count > 0 ? '$count 条书评' : '暂无书评',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
);
},
),
],
),
),
const Icon(
Icons.chevron_right,
color: Color(0xFF999999),
),
],
),
),
),
const SizedBox(height: 12),
// 摘抄入口
GestureDetector(
_buildExtraSectionItem(
icon: Icons.format_quote_outlined,
title: '摘抄',
subtitleFuture: context.read<AppProvider>().getBookExcerptCount(book.id),
emptyText: '暂无摘抄',
unit: '条摘抄',
onTap: () => _navigateToExcerpts(book),
child: Container(
padding: const EdgeInsets.all(16),
),
],
),
);
}
/// 构建更多区域项
Widget _buildExtraSectionItem({
required IconData icon,
required String title,
required Future<int> subtitleFuture,
required String emptyText,
required String unit,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
child: Icon(
icon,
size: 20,
color: const Color(0xFF666666),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.format_quote_outlined,
size: 24,
color: Color(0xFF666666),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'摘抄',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
FutureBuilder<int>(
future: context.read<AppProvider>().getBookExcerptCount(book.id),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
count > 0 ? '$count 条摘抄' : '暂无摘抄',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
);
},
),
],
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
const Icon(
Icons.chevron_right,
color: Color(0xFF999999),
const SizedBox(height: 4),
FutureBuilder<int>(
future: subtitleFuture,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
count > 0 ? '$count $unit' : emptyText,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
);
},
),
],
),
),
),
],
const Icon(
Icons.chevron_right,
color: Color(0xFFCCCCCC),
),
],
),
),
);
}
@@ -580,51 +691,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
);
}
/// 构建底部操作栏
Widget _buildBottomBar() {
return Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _navigateToEdit(context),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF1A1A1A),
side: const BorderSide(color: Color(0xFF1A1A1A)),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('编辑'),
),
),
const SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () => _showDeleteDialog(context),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('删除'),
),
),
],
),
),
),
);
}
/// 格式化日期
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
@@ -644,15 +711,32 @@ class _BookDetailPageState extends State<BookDetailPage> {
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: Text('确定要删除"${widget.book.title}"吗?'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'确认删除',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: Text(
'确定要删除"${widget.book.title}"吗?删除后可在回收站恢复。',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () async {
await context.read<AppProvider>().removeBook(widget.book.id);
if (!mounted) return;
@@ -660,9 +744,19 @@ class _BookDetailPageState extends State<BookDetailPage> {
Navigator.pop(context);
ToastUtil.show(context, '已删除');
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
@@ -674,15 +768,32 @@ class _BookDetailPageState extends State<BookDetailPage> {
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('清空封面'),
content: const Text('确定要清空封面吗?清空后将使用默认占位图。'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'清空封面',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: const Text(
'确定要清空封面吗?清空后将使用默认占位图。',
style: TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
final updatedBook = book.copyWith(
@@ -694,9 +805,19 @@ class _BookDetailPageState extends State<BookDetailPage> {
ToastUtil.show(context, '封面已清空');
}
},
child: const Text('清空', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('清空'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}

View File

@@ -29,6 +29,7 @@ class _BookFormPageState extends State<BookFormPage> {
late TextEditingController _publisherController;
late TextEditingController _summaryController;
late TextEditingController _ratingController;
late TextEditingController _isbnController;
// 多值字段的临时输入控制器
final Map<String, TextEditingController> _tagControllers = {};
@@ -39,6 +40,7 @@ class _BookFormPageState extends State<BookFormPage> {
List<String> _genres = [];
String? _coverPath;
String _status = 'want_to_read';
DateTime? _publishDate;
@override
void initState() {
@@ -63,6 +65,7 @@ class _BookFormPageState extends State<BookFormPage> {
_publisherController = TextEditingController(text: book?.publisher ?? '');
_summaryController = TextEditingController(text: book?.summary ?? '');
_ratingController = TextEditingController(text: book?.rating?.toString() ?? '');
_isbnController = TextEditingController(text: book?.isbn ?? '');
if (book != null) {
_authors = List.from(book.authors);
@@ -70,6 +73,7 @@ class _BookFormPageState extends State<BookFormPage> {
_genres = List.from(book.genres);
_coverPath = book.coverPath;
_status = book.status;
_publishDate = book.publishDate;
} else if (widget.initialStatus != null) {
// 添加模式:使用传入的默认状态
_status = widget.initialStatus!;
@@ -82,6 +86,7 @@ class _BookFormPageState extends State<BookFormPage> {
_publisherController.dispose();
_summaryController.dispose();
_ratingController.dispose();
_isbnController.dispose();
_tagControllers.values.forEach((c) => c.dispose());
super.dispose();
}
@@ -90,24 +95,51 @@ class _BookFormPageState extends State<BookFormPage> {
return _tagControllers.putIfAbsent(key, () => TextEditingController());
}
/// 构建右上角操作按钮
Widget _buildActionButton({
required IconData icon,
required VoidCallback onPressed,
required String tooltip,
Color color = const Color(0xFF1A1A1A),
}) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
icon,
color: color,
size: 22,
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final isEdit = widget.book != null;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text(isEdit ? '编辑书籍' : '添加书籍'),
actions: [
TextButton(
// 保存按钮
_buildActionButton(
icon: Icons.save_outlined,
onPressed: _saveBook,
child: const Text(
'保存',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
tooltip: '保存',
),
const SizedBox(width: 8),
],
@@ -132,9 +164,10 @@ class _BookFormPageState extends State<BookFormPage> {
const SizedBox(height: 32),
// 基本信息区域
_buildFormItem(
label: '书名 *',
// 书名
_buildHorizontalFormItem(
label: '书名',
required: true,
child: TextFormField(
controller: _titleController,
style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)),
@@ -153,10 +186,10 @@ class _BookFormPageState extends State<BookFormPage> {
),
),
_buildDivider(),
const SizedBox(height: 16),
// 别名
_buildMultiValueItem(
_buildHorizontalMultiValueItem(
label: '别名',
values: _alternateTitles,
hint: '输入别名',
@@ -165,10 +198,10 @@ class _BookFormPageState extends State<BookFormPage> {
onRemove: (i) => setState(() => _alternateTitles.removeAt(i)),
),
_buildDivider(),
const SizedBox(height: 16),
// 作者
_buildMultiValueItem(
_buildHorizontalMultiValueItem(
label: '作者',
values: _authors,
hint: '输入作者姓名',
@@ -177,10 +210,10 @@ class _BookFormPageState extends State<BookFormPage> {
onRemove: (i) => setState(() => _authors.removeAt(i)),
),
_buildDivider(),
const SizedBox(height: 16),
// 出版社
_buildFormItem(
_buildHorizontalFormItem(
label: '出版社',
child: TextFormField(
controller: _publisherController,
@@ -194,19 +227,63 @@ class _BookFormPageState extends State<BookFormPage> {
),
),
_buildDivider(),
const SizedBox(height: 16),
// 类型
_buildMultiValueItem(
_buildHorizontalMultiValueItem(
label: '类型',
values: _genres,
hint: '如:小说、历史',
hint: '如:小说、历史、传记',
controllerKey: 'genres',
onAdd: (v) => setState(() => _genres.add(v)),
onRemove: (i) => setState(() => _genres.removeAt(i)),
),
_buildDivider(),
const SizedBox(height: 16),
// ISBN
_buildHorizontalFormItem(
label: 'ISBN',
child: TextFormField(
controller: _isbnController,
style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)),
decoration: const InputDecoration(
hintText: '请输入ISBN编号',
hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
const SizedBox(height: 16),
// 出版社
_buildHorizontalFormItem(
label: '出版社',
child: TextFormField(
controller: _publisherController,
style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)),
decoration: const InputDecoration(
hintText: '请输入出版社名称',
hintStyle: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
const SizedBox(height: 16),
// 出版时间
_buildVerticalDateItem(
label: '出版时间',
date: _publishDate,
onTap: _selectPublishDate,
onClear: () => setState(() => _publishDate = null),
),
const SizedBox(height: 16),
// 书籍简介
_buildFormItem(
@@ -231,24 +308,129 @@ class _BookFormPageState extends State<BookFormPage> {
);
}
/// 构建表单条目(标签 + 内容)
/// 构建表单条目(标签 + 内容)- 书籍简介使用
Widget _buildFormItem({required String label, required Widget child}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 13,
color: label.contains('*') ? const Color(0xFF1A1A1A) : const Color(0xFF666666),
fontWeight: label.contains('*') ? FontWeight.w500 : FontWeight.normal,
),
style: const TextStyle(fontSize: 13, color: Color(0xFF666666)),
),
const SizedBox(height: 8),
child,
],
);
}
/// 构建横向表单条目(标签在左,内容在右)
Widget _buildHorizontalFormItem({
required String label,
required Widget child,
bool required = false,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 48,
child: Text(
required ? '$label *' : label,
style: TextStyle(
fontSize: 13,
color: required ? const Color(0xFF1A1A1A) : const Color(0xFF999999),
fontWeight: required ? FontWeight.w500 : FontWeight.normal,
),
),
),
const SizedBox(width: 16),
Expanded(child: child),
],
);
}
/// 构建横向多值条目(标签在左,内容在右)
Widget _buildHorizontalMultiValueItem({
required String label,
required List<String> values,
required String hint,
required String controllerKey,
required Function(String) onAdd,
required Function(int) onRemove,
}) {
final controller = _getTagController(controllerKey);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 48,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
label,
style: const TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
...values.asMap().entries.map((entry) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
entry.value,
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () => onRemove(entry.key),
child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)),
),
],
),
);
}),
// 输入框
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 80, maxWidth: 120),
child: TextField(
controller: controller,
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
decoration: InputDecoration(
hintText: values.isEmpty ? hint : '',
hintStyle: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
),
onSubmitted: (value) {
final trimmed = value.trim();
if (trimmed.isNotEmpty && !values.contains(trimmed)) {
onAdd(trimmed);
controller.clear();
}
},
),
),
],
),
),
],
);
}
/// 构建多值条目
Widget _buildMultiValueItem({
@@ -376,15 +558,7 @@ class _BookFormPageState extends State<BookFormPage> {
);
}
/// 构建分隔线
Widget _buildDivider() {
return Container(
margin: const EdgeInsets.symmetric(vertical: 16),
height: 0.5,
color: const Color(0xFFE5E5E5),
);
}
/// 构建状态选择器(靠左显示,带标签)
Widget _buildStatusSelector() {
return Row(
@@ -524,6 +698,74 @@ class _BookFormPageState extends State<BookFormPage> {
);
}
/// 构建纵向日期条目(标签在上,日期在下)
Widget _buildVerticalDateItem({
required String label,
required DateTime? date,
required VoidCallback onTap,
required VoidCallback onClear,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
const SizedBox(height: 8),
GestureDetector(
onTap: onTap,
child: Row(
children: [
Expanded(
child: Text(
date != null
? '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'
: '选择日期',
style: TextStyle(
fontSize: 15,
color: date != null
? const Color(0xFF1A1A1A)
: const Color(0xFFCCCCCC),
),
),
),
if (date != null)
GestureDetector(
onTap: onClear,
child: const Icon(Icons.close, size: 18, color: Color(0xFF999999)),
),
],
),
),
],
);
}
/// 选择出版时间
Future<void> _selectPublishDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _publishDate ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now().add(const Duration(days: 365 * 5)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Color(0xFF1A1A1A),
),
),
child: child!,
);
},
);
if (picked != null) {
setState(() => _publishDate = picked);
}
}
/// 构建状态选项
Widget _buildStatusOption(String label, String value) {
final isSelected = _status == value;
@@ -707,6 +949,8 @@ class _BookFormPageState extends State<BookFormPage> {
summary: _summaryController.text.trim(),
rating: rating,
status: _status,
isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null,
publishDate: _publishDate,
createdAt: now,
updatedAt: now,
);
@@ -723,6 +967,8 @@ class _BookFormPageState extends State<BookFormPage> {
summary: _summaryController.text.trim(),
rating: rating,
status: _status,
isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null,
publishDate: _publishDate,
updatedAt: now,
);

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/data_models.dart';
import '../../providers/app_provider.dart';
import 'book_review_form_page.dart';
/// 书评详情页
class BookReviewDetailPage extends StatefulWidget {
final BookReview review;
final String bookId;
const BookReviewDetailPage({
super.key,
required this.review,
required this.bookId,
});
@override
State<BookReviewDetailPage> createState() => _BookReviewDetailPageState();
}
class _BookReviewDetailPageState extends State<BookReviewDetailPage> {
late BookReview _review;
@override
void initState() {
super.initState();
_review = widget.review;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_refreshReviewData();
}
Future<void> _refreshReviewData() async {
final provider = context.read<AppProvider>();
final reviews = await provider.getBookReviews(widget.bookId);
final updatedReview = reviews
.where((r) => r.id == widget.review.id)
.firstOrNull;
if (updatedReview != null && updatedReview.id == _review.id) {
setState(() {
_review = updatedReview;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('书评详情'),
actions: [
// 编辑按钮
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _navigateToEdit(context),
),
const SizedBox(width: 8),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 书评内容
Text(
_review.content,
style: const TextStyle(
fontSize: 16,
color: Color(0xFF1A1A1A),
height: 1.8,
),
),
const SizedBox(height: 32),
// 分隔线
Container(
height: 0.5,
color: const Color(0xFFE5E5E5),
),
const SizedBox(height: 24),
// 书评人
_buildInfoRow(
icon: Icons.person_outline,
label: '书评人:',
value: _review.reviewer.isNotEmpty ? _review.reviewer : '匿名',
),
const SizedBox(height: 16),
// 来源
if (_review.source.isNotEmpty)
_buildInfoRow(
icon: Icons.source_outlined,
label: '来源:',
value: _review.source,
),
if (_review.source.isNotEmpty) const SizedBox(height: 16),
// 类型
_buildInfoRow(
icon: Icons.category_outlined,
label: '类型:',
value: _review.typeText,
),
const SizedBox(height: 16),
// 时间
_buildInfoRow(
icon: Icons.access_time,
label: '时间:',
value: _formatDate(_review.createdAt),
),
],
),
),
);
}
/// 构建信息行
Widget _buildInfoRow({
required IconData icon,
required String label,
required String value,
}) {
return Row(
children: [
Icon(
icon,
size: 20,
color: const Color(0xFF999999),
),
const SizedBox(width: 12),
Text(
label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
),
),
const SizedBox(width: 16),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
),
),
),
],
);
}
String _formatDate(DateTime date) {
return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
}
void _navigateToEdit(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookReviewFormPage(
bookId: widget.bookId,
review: _review,
),
),
).then((_) => _refreshReviewData());
}
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../../providers/app_provider.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import 'book_review_form_page.dart';
import 'book_review_detail_page.dart';
/// 书籍书评列表页面
class BookReviewsPage extends StatefulWidget {
@@ -17,7 +19,10 @@ class BookReviewsPage extends StatefulWidget {
class _BookReviewsPageState extends State<BookReviewsPage> {
List<BookReview> _reviews = [];
List<BookReview> _filteredReviews = [];
bool _isLoading = true;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
@@ -25,18 +30,45 @@ class _BookReviewsPageState extends State<BookReviewsPage> {
_loadReviews();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadReviews() async {
setState(() => _isLoading = true);
try {
final reviews = await context.read<AppProvider>().getBookReviews(widget.book.id);
setState(() {
_reviews = reviews;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
ToastUtil.show(context, '加载失败: $e');
}
final reviews = await context.read<AppProvider>().getBookReviews(widget.book.id);
setState(() {
_reviews = reviews;
_filteredReviews = reviews;
_isLoading = false;
});
}
void _toggleSearch() {
setState(() {
_isSearching = !_isSearching;
if (!_isSearching) {
_searchController.clear();
_filteredReviews = _reviews;
}
});
}
void _onSearchChanged(String query) {
setState(() {
if (query.isEmpty) {
_filteredReviews = _reviews;
} else {
final lowerQuery = query.toLowerCase();
_filteredReviews = _reviews.where((review) {
return review.content.toLowerCase().contains(lowerQuery) ||
review.reviewer.toLowerCase().contains(lowerQuery) ||
review.source.toLowerCase().contains(lowerQuery);
}).toList();
}
});
}
@override
@@ -44,8 +76,26 @@ class _BookReviewsPageState extends State<BookReviewsPage> {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('书评'),
title: _isSearching
? TextField(
controller: _searchController,
autofocus: true,
decoration: const InputDecoration(
hintText: '搜索书评内容、书评人、来源...',
hintStyle: TextStyle(color: Color(0xFF999999)),
border: InputBorder.none,
),
style: const TextStyle(color: Color(0xFF1A1A1A)),
onChanged: _onSearchChanged,
)
: const Text('书评'),
actions: [
// 搜索按钮
IconButton(
icon: Icon(_isSearching ? Icons.close : Icons.search),
onPressed: _toggleSearch,
),
// 添加按钮
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _navigateToAddReview(),
@@ -55,7 +105,7 @@ class _BookReviewsPageState extends State<BookReviewsPage> {
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _reviews.isEmpty
: _filteredReviews.isEmpty
? _buildEmptyState()
: _buildReviewList(),
);
@@ -95,82 +145,63 @@ class _BookReviewsPageState extends State<BookReviewsPage> {
}
Widget _buildReviewList() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _reviews.length,
return MasonryGridView.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
padding: const EdgeInsets.all(12),
itemCount: _filteredReviews.length,
itemBuilder: (context, index) {
final review = _reviews[index];
return _buildReviewItem(review);
final review = _filteredReviews[index];
return _buildReviewCard(review);
},
);
}
Widget _buildReviewItem(BookReview review) {
Widget _buildReviewCard(BookReview review) {
return InkWell(
onTap: () => _navigateToReviewDetail(review),
onLongPress: () => _showDeleteDialog(review),
child: Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部:类型标签 + 操作按钮
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: review.reviewType == 1
? const Color(0xFFF5F5F5)
: const Color(0xFF1A1A1A),
),
child: Text(
review.typeText,
style: TextStyle(
fontSize: 11,
color: review.reviewType == 1
? const Color(0xFF666666)
: Colors.white,
),
),
// 类型标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: review.reviewType == 1
? Colors.white
: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(4),
),
child: Text(
review.typeText,
style: TextStyle(
fontSize: 10,
color: review.reviewType == 1
? const Color(0xFF666666)
: Colors.white,
),
const Spacer(),
// 编辑按钮
GestureDetector(
onTap: () => _navigateToEditReview(review),
child: const Icon(
Icons.edit_outlined,
size: 18,
color: Color(0xFF999999),
),
),
const SizedBox(width: 16),
// 删除按钮
GestureDetector(
onTap: () => _showDeleteDialog(review),
child: const Icon(
Icons.delete_outline,
size: 18,
color: Colors.red,
),
),
],
),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
// 评论内容
Text(
review.content,
maxLines: review.reviewType == 1 ? 3 : 5,
maxLines: review.reviewType == 1 ? 4 : 8,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontSize: 13,
color: Color(0xFF1A1A1A),
height: 1.6,
height: 1.5,
),
),
@@ -179,31 +210,43 @@ class _BookReviewsPageState extends State<BookReviewsPage> {
// 底部信息
Row(
children: [
if (review.reviewer.isNotEmpty) ...[
Text(
review.reviewer,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
// 书评人
if (review.reviewer.isNotEmpty)
Expanded(
child: Text(
review.reviewer,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF666666),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
],
if (review.source.isNotEmpty) ...[
Text(
'来源:${review.source}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
],
),
const SizedBox(height: 4),
// 来源和日期一行
Row(
children: [
// 来源
if (review.source.isNotEmpty)
Expanded(
child: Text(
review.source,
style: const TextStyle(
fontSize: 10,
color: Color(0xFF999999),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
],
const Spacer(),
// 日期
Text(
_formatDate(review.createdAt),
style: const TextStyle(
fontSize: 12,
fontSize: 10,
color: Color(0xFF999999),
),
),
@@ -240,6 +283,18 @@ class _BookReviewsPageState extends State<BookReviewsPage> {
).then((_) => _loadReviews());
}
void _navigateToReviewDetail(BookReview review) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookReviewDetailPage(
review: review,
bookId: widget.book.id,
),
),
).then((_) => _loadReviews());
}
void _showDeleteDialog(BookReview review) {
showDialog(
context: context,

View File

@@ -94,11 +94,41 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
],
),
// 底部操作栏
bottomNavigationBar: _buildBottomBar(),
// 底部操作栏,编辑和删除在右上角
);
}
/// 构建右上角操作按钮
Widget _buildActionButton({
required IconData icon,
required VoidCallback onPressed,
required String tooltip,
Color color = const Color(0xFF666666),
}) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
icon,
color: color,
size: 22,
),
),
),
),
);
}
/// 构建顶部 AppBar
Widget _buildSliverAppBar(Movie movie) {
final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty;
@@ -113,40 +143,30 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
actions: [
// 下载海报按钮(仅当有海报时显示)
if (hasPoster)
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.download_outlined, color: Color(0xFF666666)),
onPressed: () => _downloadPoster(movie),
tooltip: '下载海报',
),
_buildActionButton(
icon: Icons.download_outlined,
onPressed: () => _downloadPoster(movie),
tooltip: '下载海报',
),
// 清空海报按钮(仅当有海报时显示)
if (hasPoster)
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.hide_image_outlined, color: Color(0xFF666666)),
onPressed: () => _showClearPosterDialog(movie),
tooltip: '清空海报',
),
_buildActionButton(
icon: Icons.hide_image_outlined,
onPressed: () => _showClearPosterDialog(movie),
tooltip: '清空海报',
),
// 编辑按钮
Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white,
),
child: IconButton(
icon: const Icon(Icons.edit_outlined, color: Color(0xFF1A1A1A)),
onPressed: () => _navigateToEdit(context),
),
_buildActionButton(
icon: Icons.edit_outlined,
onPressed: () => _navigateToEdit(context),
tooltip: '编辑',
),
// 删除按钮
_buildActionButton(
icon: Icons.delete_outline,
color: Colors.red,
onPressed: () => _showDeleteDialog(context),
tooltip: '删除',
),
const SizedBox(width: 8),
],
@@ -196,15 +216,32 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('清空海报'),
content: const Text('确定要清空海报吗?清空后将使用默认占位图。'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'清空海报',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: const Text(
'确定要清空海报吗?清空后将使用默认占位图。',
style: TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
final updatedMovie = movie.copyWith(
@@ -216,9 +253,19 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
ToastUtil.show(context, '海报已清空');
}
},
child: const Text('清空', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('清空'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
@@ -285,23 +332,24 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
// 上映日期
if (movie.releaseDate != null)
Text(
'${movie.releaseDate!.year}年上映',
'${movie.releaseDate!.year}${movie.releaseDate!.month.toString().padLeft(2, '0')}上映',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
),
),
const SizedBox(height: 8),
// 时间信息
Text(
'添加于 ${_formatDate(movie.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
// 观看日期
if (movie.watchDate != null)
Text(
'观看于 ${_formatDate(movie.watchDate!)}',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
),
),
),
],
),
);
@@ -310,36 +358,42 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
/// 构建状态标签
Widget _buildStatusTag(Movie movie) {
String label;
Color color;
Color bgColor;
Color textColor;
switch (movie.status) {
case 'watched':
label = '已看';
color = const Color(0xFF1A1A1A);
bgColor = const Color(0xFF1A1A1A);
textColor = Colors.white;
break;
case 'watching':
label = '在看';
color = const Color(0xFF666666);
bgColor = const Color(0xFFF0F0F0);
textColor = const Color(0xFF666666);
break;
case 'want_to_watch':
label = '想看';
color = const Color(0xFF999999);
bgColor = const Color(0xFFF5F5F5);
textColor = const Color(0xFF999999);
break;
default:
label = '未知';
color = const Color(0xFFCCCCCC);
bgColor = const Color(0xFFEEEEEE);
textColor = const Color(0xFFCCCCCC);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color,
color: bgColor,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
color: textColor,
fontWeight: FontWeight.w600,
),
),
);
@@ -348,164 +402,137 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
/// 构建导演区域
Widget _buildDirectorsSection(Movie movie) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'导演',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
const SizedBox(
width: 48,
child: Text(
'导演',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: movie.directors.map((director) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Text(
director,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1A1A1A),
),
),
);
}).toList(),
Expanded(
child: Text(
movie.directors.join(''),
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建编剧区域
Widget _buildWritersSection(Movie movie) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'编剧',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
const SizedBox(
width: 48,
child: Text(
'编剧',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: movie.writers.map((writer) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Text(
writer,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1A1A1A),
),
),
);
}).toList(),
Expanded(
child: Text(
movie.writers.join(''),
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建主演区域
Widget _buildActorsSection(Movie movie) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'主演',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
const SizedBox(
width: 48,
child: Text(
'主演',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: movie.actors.map((actor) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Text(
actor,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1A1A1A),
),
),
);
}).toList(),
Expanded(
child: Text(
movie.actors.join(''),
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.5,
),
),
),
],
),
);
}
/// 构建类型区域
Widget _buildGenresSection(Movie movie) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'类型',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
const SizedBox(
width: 48,
child: Text(
'类型',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: movie.genres.map((genre) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Text(
genre,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
Expanded(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: movie.genres.map((genre) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
),
),
);
}).toList(),
child: Text(
genre,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
),
);
}).toList(),
),
),
],
),
@@ -519,22 +546,41 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'简介',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
),
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'简介',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 12),
Text(
movie.summary!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.6,
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Text(
movie.summary!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.8,
),
),
),
],
@@ -549,124 +595,122 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'更多',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
letterSpacing: 1,
),
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'更多',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 16),
// 影评入口
GestureDetector(
_buildExtraSectionItem(
icon: Icons.rate_review_outlined,
title: '影评',
subtitleFuture: context.read<AppProvider>().getMovieReviewCount(movie.id),
emptyText: '暂无影评',
unit: '条影评',
onTap: () => _navigateToReviews(movie),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Row(
children: [
const Icon(
Icons.rate_review_outlined,
size: 24,
color: Color(0xFF666666),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'影评',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
FutureBuilder<int>(
future: context.read<AppProvider>().getMovieReviewCount(movie.id),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
count > 0 ? '$count 条影评' : '暂无影评',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
);
},
),
],
),
),
const Icon(
Icons.chevron_right,
color: Color(0xFF999999),
),
],
),
),
),
const SizedBox(height: 12),
// 海报墙入口
GestureDetector(
_buildExtraSectionItem(
icon: Icons.photo_library_outlined,
title: '海报墙',
subtitleFuture: context.read<AppProvider>().getMoviePosterCount(movie.id),
emptyText: '暂无海报',
unit: '张海报',
onTap: () => _navigateToPosters(movie),
child: Container(
padding: const EdgeInsets.all(16),
),
],
),
);
}
/// 构建更多区域项
Widget _buildExtraSectionItem({
required IconData icon,
required String title,
required Future<int> subtitleFuture,
required String emptyText,
required String unit,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
child: Icon(
icon,
size: 20,
color: const Color(0xFF666666),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.photo_library_outlined,
size: 24,
color: Color(0xFF666666),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'海报墙',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
FutureBuilder<int>(
future: context.read<AppProvider>().getMoviePosterCount(movie.id),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
count > 0 ? '$count 张海报' : '暂无海报',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
);
},
),
],
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
const Icon(
Icons.chevron_right,
color: Color(0xFF999999),
const SizedBox(height: 4),
FutureBuilder<int>(
future: subtitleFuture,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Text(
count > 0 ? '$count $unit' : emptyText,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
);
},
),
],
),
),
),
],
const Icon(
Icons.chevron_right,
color: Color(0xFFCCCCCC),
),
],
),
),
);
}
@@ -689,51 +733,6 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
);
}
/// 构建底部操作栏
Widget _buildBottomBar() {
return Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _navigateToEdit(context),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF1A1A1A),
side: const BorderSide(color: Color(0xFF1A1A1A)),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('编辑'),
),
),
const SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () => _showDeleteDialog(context),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('删除'),
),
),
],
),
),
),
);
}
/// 格式化日期
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
@@ -753,15 +752,32 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: Text('确定要删除"${widget.movie.title}"吗?'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'确认删除',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: Text(
'确定要删除"${widget.movie.title}"吗?删除后可在回收站恢复。',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () async {
await context.read<AppProvider>().removeMovie(widget.movie.id);
if (!mounted) return;
@@ -769,9 +785,19 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
Navigator.pop(context);
ToastUtil.show(context, '已删除');
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}

View File

@@ -41,6 +41,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
String? _posterPath;
String _status = 'want_to_watch';
DateTime? _releaseDate;
DateTime? _watchDate;
@override
void initState() {
@@ -74,6 +75,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
_posterPath = movie.posterPath;
_status = movie.status;
_releaseDate = movie.releaseDate;
_watchDate = movie.watchDate;
} else if (widget.initialStatus != null) {
// 添加模式:使用传入的默认状态
_status = widget.initialStatus!;
@@ -92,7 +94,38 @@ class _MovieFormPageState extends State<MovieFormPage> {
TextEditingController _getTagController(String key) {
return _tagControllers.putIfAbsent(key, () => TextEditingController());
}
/// 构建右上角操作按钮
Widget _buildActionButton({
required IconData icon,
required VoidCallback onPressed,
required String tooltip,
Color color = const Color(0xFF1A1A1A),
}) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
icon,
color: color,
size: 22,
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final isEdit = widget.movie != null;
@@ -102,15 +135,11 @@ class _MovieFormPageState extends State<MovieFormPage> {
appBar: AppBar(
title: Text(isEdit ? '编辑影视' : '添加影视'),
actions: [
TextButton(
// 保存按钮
_buildActionButton(
icon: Icons.save_outlined,
onPressed: _saveMovie,
child: const Text(
'保存',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
tooltip: '保存',
),
const SizedBox(width: 8),
],
@@ -135,9 +164,10 @@ class _MovieFormPageState extends State<MovieFormPage> {
const SizedBox(height: 32),
// 基本信息区域
_buildFormItem(
label: '影视名称 *',
// 影视名称
_buildHorizontalFormItem(
label: '名称',
required: true,
child: TextFormField(
controller: _titleController,
style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)),
@@ -155,11 +185,11 @@ class _MovieFormPageState extends State<MovieFormPage> {
},
),
),
_buildDivider(),
const SizedBox(height: 16),
// 别名
_buildMultiValueItem(
_buildHorizontalMultiValueItem(
label: '别名',
values: _alternateTitles,
hint: '输入别名',
@@ -167,43 +197,11 @@ class _MovieFormPageState extends State<MovieFormPage> {
onAdd: (v) => setState(() => _alternateTitles.add(v)),
onRemove: (i) => setState(() => _alternateTitles.removeAt(i)),
),
_buildDivider(),
// 上映日期
_buildFormItem(
label: '上映日期',
child: GestureDetector(
onTap: _selectReleaseDate,
child: Row(
children: [
Expanded(
child: Text(
_releaseDate != null
? '${_releaseDate!.year}.${_releaseDate!.month.toString().padLeft(2, '0')}.${_releaseDate!.day.toString().padLeft(2, '0')}'
: '选择日期',
style: TextStyle(
fontSize: 15,
color: _releaseDate != null
? const Color(0xFF1A1A1A)
: const Color(0xFFCCCCCC),
),
),
),
if (_releaseDate != null)
GestureDetector(
onTap: () => setState(() => _releaseDate = null),
child: const Icon(Icons.close, size: 18, color: Color(0xFF999999)),
),
],
),
),
),
_buildDivider(),
const SizedBox(height: 16),
// 导演
_buildMultiValueItem(
_buildHorizontalMultiValueItem(
label: '导演',
values: _directors,
hint: '输入导演姓名',
@@ -211,11 +209,11 @@ class _MovieFormPageState extends State<MovieFormPage> {
onAdd: (v) => setState(() => _directors.add(v)),
onRemove: (i) => setState(() => _directors.removeAt(i)),
),
_buildDivider(),
const SizedBox(height: 16),
// 编剧
_buildMultiValueItem(
_buildHorizontalMultiValueItem(
label: '编剧',
values: _writers,
hint: '输入编剧姓名',
@@ -223,11 +221,11 @@ class _MovieFormPageState extends State<MovieFormPage> {
onAdd: (v) => setState(() => _writers.add(v)),
onRemove: (i) => setState(() => _writers.removeAt(i)),
),
_buildDivider(),
const SizedBox(height: 16),
// 主演
_buildMultiValueItem(
_buildHorizontalMultiValueItem(
label: '主演',
values: _actors,
hint: '输入主演姓名',
@@ -235,20 +233,40 @@ class _MovieFormPageState extends State<MovieFormPage> {
onAdd: (v) => setState(() => _actors.add(v)),
onRemove: (i) => setState(() => _actors.removeAt(i)),
),
_buildDivider(),
// 类型
_buildMultiValueItem(
const SizedBox(height: 16),
// 类型(自定义填写,带示例)
_buildHorizontalMultiValueItem(
label: '类型',
values: _genres,
hint: '如:剧情、科幻',
hint: '如:剧情、科幻、悬疑',
controllerKey: 'genres',
onAdd: (v) => setState(() => _genres.add(v)),
onRemove: (i) => setState(() => _genres.removeAt(i)),
),
_buildDivider(),
const SizedBox(height: 16),
// 上映日期
_buildVerticalDateItem(
label: '上映日期',
date: _releaseDate,
onTap: _selectReleaseDate,
onClear: () => setState(() => _releaseDate = null),
),
const SizedBox(height: 16),
// 观看日期
_buildVerticalDateItem(
label: '观看日期',
date: _watchDate,
onTap: _selectWatchDate,
onClear: () => setState(() => _watchDate = null),
),
const SizedBox(height: 16),
// 剧情简介
_buildFormItem(
@@ -273,25 +291,174 @@ class _MovieFormPageState extends State<MovieFormPage> {
);
}
/// 构建表单条目(标签 + 内容
/// 构建纵向日期条目(标签在上,日期在下
Widget _buildVerticalDateItem({
required String label,
required DateTime? date,
required VoidCallback onTap,
required VoidCallback onClear,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
const SizedBox(height: 8),
GestureDetector(
onTap: onTap,
child: Row(
children: [
Expanded(
child: Text(
date != null
? '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'
: '选择日期',
style: TextStyle(
fontSize: 15,
color: date != null
? const Color(0xFF1A1A1A)
: const Color(0xFFCCCCCC),
),
),
),
if (date != null)
GestureDetector(
onTap: onClear,
child: const Icon(Icons.close, size: 18, color: Color(0xFF999999)),
),
],
),
),
],
);
}
/// 构建表单条目(标签 + 内容)- 剧情简介使用
Widget _buildFormItem({required String label, required Widget child}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 13,
color: label.contains('*') ? const Color(0xFF1A1A1A) : const Color(0xFF666666),
fontWeight: label.contains('*') ? FontWeight.w500 : FontWeight.normal,
),
style: const TextStyle(fontSize: 13, color: Color(0xFF666666)),
),
const SizedBox(height: 8),
child,
],
);
}
/// 构建横向表单条目(标签在左,内容在右)
Widget _buildHorizontalFormItem({
required String label,
required Widget child,
bool required = false,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 48,
child: Text(
required ? '$label *' : label,
style: TextStyle(
fontSize: 13,
color: required ? const Color(0xFF1A1A1A) : const Color(0xFF999999),
fontWeight: required ? FontWeight.w500 : FontWeight.normal,
),
),
),
const SizedBox(width: 16),
Expanded(child: child),
],
);
}
/// 构建横向多值条目(标签在左,内容在右)
Widget _buildHorizontalMultiValueItem({
required String label,
required List<String> values,
required String hint,
required String controllerKey,
required Function(String) onAdd,
required Function(int) onRemove,
}) {
final controller = _getTagController(controllerKey);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 48,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
label,
style: const TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
...values.asMap().entries.map((entry) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
entry.value,
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () => onRemove(entry.key),
child: const Icon(Icons.close, size: 14, color: Color(0xFF999999)),
),
],
),
);
}),
// 输入框
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 80, maxWidth: 120),
child: TextField(
controller: controller,
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
decoration: InputDecoration(
hintText: values.isEmpty ? hint : '',
hintStyle: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
),
onSubmitted: (value) {
final trimmed = value.trim();
if (trimmed.isNotEmpty && !values.contains(trimmed)) {
onAdd(trimmed);
controller.clear();
}
},
),
),
],
),
),
],
);
}
/// 构建多值条目
Widget _buildMultiValueItem({
required String label,
@@ -418,15 +585,6 @@ class _MovieFormPageState extends State<MovieFormPage> {
);
}
/// 构建分隔线
Widget _buildDivider() {
return Container(
margin: const EdgeInsets.symmetric(vertical: 16),
height: 0.5,
color: const Color(0xFFE5E5E5),
);
}
/// 构建状态选择器(靠左显示,带标签)
Widget _buildStatusSelector() {
return Row(
@@ -716,7 +874,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
}
}
/// 选择日期
/// 选择上映日期
Future<void> _selectReleaseDate() async {
final picked = await showDatePicker(
context: context,
@@ -739,6 +897,30 @@ class _MovieFormPageState extends State<MovieFormPage> {
setState(() => _releaseDate = picked);
}
}
/// 选择观看日期
Future<void> _selectWatchDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _watchDate ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now().add(const Duration(days: 365)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Color(0xFF1A1A1A),
),
),
child: child!,
);
},
);
if (picked != null) {
setState(() => _watchDate = picked);
}
}
/// 保存影视
Future<void> _saveMovie() async {
@@ -775,6 +957,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
summary: _summaryController.text.trim(),
rating: rating,
status: _status,
watchDate: _watchDate,
createdAt: now,
updatedAt: now,
);
@@ -793,6 +976,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
summary: _summaryController.text.trim(),
rating: rating,
status: _status,
watchDate: _watchDate,
updatedAt: now,
);

View File

@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/data_models.dart';
import '../../providers/app_provider.dart';
import 'movie_review_form_page.dart';
/// 影评详情页
class MovieReviewDetailPage extends StatefulWidget {
final MovieReview review;
final String movieId;
const MovieReviewDetailPage({
super.key,
required this.review,
required this.movieId,
});
@override
State<MovieReviewDetailPage> createState() => _MovieReviewDetailPageState();
}
class _MovieReviewDetailPageState extends State<MovieReviewDetailPage> {
late MovieReview _review;
@override
void initState() {
super.initState();
_review = widget.review;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_refreshReviewData();
}
Future<void> _refreshReviewData() async {
final provider = context.read<AppProvider>();
final reviews = await provider.getMovieReviews(widget.movieId);
final updatedReview = reviews
.where((r) => r.id == widget.review.id)
.firstOrNull;
if (updatedReview != null && updatedReview.id == _review.id) {
setState(() {
_review = updatedReview;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('影评详情'),
actions: [
// 编辑按钮
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _navigateToEdit(context),
),
const SizedBox(width: 8),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 影评内容
Text(
_review.content,
style: const TextStyle(
fontSize: 16,
color: Color(0xFF1A1A1A),
height: 1.8,
),
),
const SizedBox(height: 32),
// 分隔线
Container(
height: 0.5,
color: const Color(0xFFE5E5E5),
),
const SizedBox(height: 24),
// 影评人
_buildInfoRow(
icon: Icons.person_outline,
label: '影评人:',
value: _review.reviewer.isNotEmpty ? _review.reviewer : '匿名',
),
const SizedBox(height: 16),
// 来源
if (_review.source.isNotEmpty)
_buildInfoRow(
icon: Icons.source_outlined,
label: '来源:',
value: _review.source,
),
if (_review.source.isNotEmpty) const SizedBox(height: 16),
// 类型
_buildInfoRow(
icon: Icons.category_outlined,
label: '类型:',
value: _review.typeText,
),
const SizedBox(height: 16),
// 时间
_buildInfoRow(
icon: Icons.access_time,
label: '时间:',
value: _formatDate(_review.createdAt),
),
],
),
),
);
}
/// 构建信息行
Widget _buildInfoRow({
required IconData icon,
required String label,
required String value,
}) {
return Row(
children: [
Icon(
icon,
size: 20,
color: const Color(0xFF999999),
),
const SizedBox(width: 12),
// 固定宽度容器,以"影评人:"的最大宽度为准
SizedBox(
width: 64,
child: Text(
label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
),
),
),
],
);
}
String _formatDate(DateTime date) {
return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
}
void _navigateToEdit(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MovieReviewFormPage(
movieId: widget.movieId,
review: _review,
),
),
).then((_) => _refreshReviewData());
}
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../../providers/app_provider.dart';
import '../../models/data_models.dart';
import '../../utils/toast_util.dart';
import 'movie_review_form_page.dart';
import 'movie_review_detail_page.dart';
/// 影视影评列表页面
class MovieReviewsPage extends StatefulWidget {
@@ -17,7 +19,10 @@ class MovieReviewsPage extends StatefulWidget {
class _MovieReviewsPageState extends State<MovieReviewsPage> {
List<MovieReview> _reviews = [];
List<MovieReview> _filteredReviews = [];
bool _isLoading = true;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
@@ -25,22 +30,72 @@ class _MovieReviewsPageState extends State<MovieReviewsPage> {
_loadReviews();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadReviews() async {
setState(() => _isLoading = true);
final reviews = await context.read<AppProvider>().getMovieReviews(widget.movie.id);
setState(() {
_reviews = reviews;
_filteredReviews = reviews;
_isLoading = false;
});
}
void _toggleSearch() {
setState(() {
_isSearching = !_isSearching;
if (!_isSearching) {
_searchController.clear();
_filteredReviews = _reviews;
}
});
}
void _onSearchChanged(String query) {
setState(() {
if (query.isEmpty) {
_filteredReviews = _reviews;
} else {
final lowerQuery = query.toLowerCase();
_filteredReviews = _reviews.where((review) {
return review.content.toLowerCase().contains(lowerQuery) ||
review.reviewer.toLowerCase().contains(lowerQuery) ||
review.source.toLowerCase().contains(lowerQuery);
}).toList();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('影评'),
title: _isSearching
? TextField(
controller: _searchController,
autofocus: true,
decoration: const InputDecoration(
hintText: '搜索影评内容、评论人、来源...',
hintStyle: TextStyle(color: Color(0xFF999999)),
border: InputBorder.none,
),
style: const TextStyle(color: Color(0xFF1A1A1A)),
onChanged: _onSearchChanged,
)
: const Text('影评'),
actions: [
// 搜索按钮
IconButton(
icon: Icon(_isSearching ? Icons.close : Icons.search),
onPressed: _toggleSearch,
),
// 添加按钮
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _navigateToAddReview(),
@@ -50,7 +105,7 @@ class _MovieReviewsPageState extends State<MovieReviewsPage> {
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _reviews.isEmpty
: _filteredReviews.isEmpty
? _buildEmptyState()
: _buildReviewList(),
);
@@ -90,115 +145,108 @@ class _MovieReviewsPageState extends State<MovieReviewsPage> {
}
Widget _buildReviewList() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _reviews.length,
return MasonryGridView.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
padding: const EdgeInsets.all(12),
itemCount: _filteredReviews.length,
itemBuilder: (context, index) {
final review = _reviews[index];
return _buildReviewItem(review);
final review = _filteredReviews[index];
return _buildReviewCard(review);
},
);
}
Widget _buildReviewItem(MovieReview review) {
Widget _buildReviewCard(MovieReview review) {
return InkWell(
onTap: () => _navigateToReviewDetail(review),
onLongPress: () => _showDeleteDialog(review),
child: Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部:类型标签 + 操作按钮
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: review.reviewType == 1
? const Color(0xFFF5F5F5)
: const Color(0xFF1A1A1A),
),
child: Text(
review.typeText,
style: TextStyle(
fontSize: 11,
color: review.reviewType == 1
? const Color(0xFF666666)
: Colors.white,
),
),
// 类型标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: review.reviewType == 1
? Colors.white
: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(4),
),
child: Text(
review.typeText,
style: TextStyle(
fontSize: 10,
color: review.reviewType == 1
? const Color(0xFF666666)
: Colors.white,
),
const Spacer(),
// 编辑按钮
GestureDetector(
onTap: () => _navigateToEditReview(review),
child: const Icon(
Icons.edit_outlined,
size: 18,
color: Color(0xFF999999),
),
),
const SizedBox(width: 16),
// 删除按钮
GestureDetector(
onTap: () => _showDeleteDialog(review),
child: const Icon(
Icons.delete_outline,
size: 18,
color: Colors.red,
),
),
],
),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
// 评论内容
Text(
review.content,
maxLines: review.reviewType == 1 ? 3 : 5,
maxLines: review.reviewType == 1 ? 4 : 8,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontSize: 13,
color: Color(0xFF1A1A1A),
height: 1.6,
height: 1.5,
),
),
const SizedBox(height: 12),
// 底部信息
Row(
children: [
if (review.reviewer.isNotEmpty) ...[
Text(
review.reviewer,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
// 评论人
if (review.reviewer.isNotEmpty)
Expanded(
child: Text(
review.reviewer,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF666666),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
],
if (review.source.isNotEmpty) ...[
Text(
'来源:${review.source}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
],
),
const SizedBox(height: 4),
// 来源和日期一行
Row(
children: [
// 来源
if (review.source.isNotEmpty)
Expanded(
child: Text(
review.source,
style: const TextStyle(
fontSize: 10,
color: Color(0xFF999999),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
],
const Spacer(),
// 日期
Text(
_formatDate(review.createdAt),
style: const TextStyle(
fontSize: 12,
fontSize: 10,
color: Color(0xFF999999),
),
),
@@ -235,6 +283,18 @@ class _MovieReviewsPageState extends State<MovieReviewsPage> {
).then((_) => _loadReviews());
}
void _navigateToReviewDetail(MovieReview review) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MovieReviewDetailPage(
review: review,
movieId: widget.movie.id,
),
),
).then((_) => _loadReviews());
}
void _showDeleteDialog(MovieReview review) {
showDialog(
context: context,

View File

@@ -77,28 +77,29 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
// 标签区域
if (note.tags.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
bottom: BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
),
child: Row(
children: [
Wrap(
spacing: 8,
runSpacing: 8,
spacing: 10,
runSpacing: 10,
children: note.tags.map((tag) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Text(
tag,
style: const TextStyle(
fontSize: 12,
fontSize: 13,
color: Color(0xFF666666),
),
),
@@ -119,10 +120,10 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
// 图片区域(仅在纯文本模式下显示)
if (note.contentType == 'plain_text' && note.images.isNotEmpty)
Container(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
top: BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
),
child: Column(
@@ -131,25 +132,35 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
// 图片标题
Row(
children: [
const Icon(
Icons.image_outlined,
size: 14,
color: Color(0xFF999999),
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: const Icon(
Icons.image_outlined,
size: 18,
color: Color(0xFF666666),
),
),
const SizedBox(width: 6),
const SizedBox(width: 12),
Text(
'图片 (${note.images.length})',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 16),
// 图片列表
SizedBox(
height: 100,
height: 110,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: note.images.length,
@@ -157,12 +168,14 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
return GestureDetector(
onTap: () => _showImagePreview(context, note.images, index),
child: Container(
width: 100,
height: 100,
width: 110,
height: 110,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: Image.file(
File(note.images[index]),
fit: BoxFit.cover,
@@ -180,58 +193,51 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
top: BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
// 创建时间和更新时间
// 时间信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'创建 ${_formatDateTime(note.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
const SizedBox(height: 4),
Text(
'更新 ${_formatDateTime(note.updatedAt)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
),
),
// 操作按钮
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'创建时间:${_formatDateTime(note.createdAt)}',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
),
),
const SizedBox(height: 4),
Text(
'更新时间:${_formatDateTime(note.updatedAt)}',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
),
),
],
),
_buildActionButton(
icon: Icons.edit_outlined,
color: const Color(0xFF666666),
onTap: () => _navigateToEdit(context),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: const Color(0xFF666666),
onPressed: () => _navigateToEdit(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: Colors.red,
onPressed: () => _showDeleteDialog(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
const SizedBox(width: 12),
_buildActionButton(
icon: Icons.delete_outline,
color: Colors.red,
onTap: () => _showDeleteDialog(context),
),
],
),
@@ -349,8 +355,9 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
(n) => n.id == widget.note.id,
orElse: () => widget.note,
);
Navigator.pushNamed(context, '/note-form', arguments: currentNote).then((_) {
context.read<AppProvider>().loadNotes();
Navigator.pushNamed(context, '/note-form', arguments: currentNote).then((_) async {
// 返回时刷新笔记列表
await context.read<AppProvider>().loadNotes();
});
}
@@ -380,6 +387,35 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
);
}
/// 构建操作按钮
Widget _buildActionButton({
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Icon(
icon,
color: color,
size: 20,
),
),
),
);
}
/// 显示删除对话框
void _showDeleteDialog(BuildContext context) {
showDialog(
@@ -387,25 +423,54 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: const Text('确定要删除这条笔记吗?'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'确认删除',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: const Text(
'确定要删除这条笔记吗?删除后可在回收站恢复。',
style: TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () async {
await context.read<AppProvider>().removeNote(widget.note.id);
// 刷新笔记列表
await context.read<AppProvider>().loadNotes();
if (!mounted) return;
Navigator.pop(context);
Navigator.pop(context);
ToastUtil.show(context, '已删除');
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}

View File

@@ -53,15 +53,10 @@ class _NoteFormPageState extends State<NoteFormPage> {
appBar: AppBar(
title: Text(_isEditing ? '编辑笔记' : '新建笔记'),
actions: [
TextButton(
IconButton(
onPressed: _saveNote,
child: const Text(
'保存',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
icon: const Icon(Icons.save_outlined),
tooltip: '保存',
),
const SizedBox(width: 8),
],
@@ -70,10 +65,10 @@ class _NoteFormPageState extends State<NoteFormPage> {
children: [
// 顶部信息栏:创建时间 + 格式选择 + 标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
bottom: BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
),
child: Column(
@@ -82,11 +77,19 @@ class _NoteFormPageState extends State<NoteFormPage> {
Row(
children: [
// 创建时间
Text(
_formatDateTime(_createdAt),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Text(
_formatDateTime(_createdAt),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF666666),
),
),
),
const Spacer(),
@@ -94,7 +97,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
_buildFormatSelector(),
],
),
const SizedBox(height: 8),
const SizedBox(height: 12),
// 标签
_buildTagSelector(),
],
@@ -155,18 +158,33 @@ class _NoteFormPageState extends State<NoteFormPage> {
// 图片网格区域
Expanded(
child: Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题栏
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: const Icon(
Icons.image_outlined,
size: 18,
color: Color(0xFF666666),
),
),
const SizedBox(width: 12),
const Text(
'图片',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
@@ -174,7 +192,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
Text(
'${_images.length}',
style: const TextStyle(
fontSize: 14,
fontSize: 15,
color: Color(0xFF999999),
),
),
@@ -183,24 +201,25 @@ class _NoteFormPageState extends State<NoteFormPage> {
InkWell(
onTap: _pickImage,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.add,
size: 16,
size: 18,
color: Colors.white,
),
SizedBox(width: 4),
SizedBox(width: 6),
Text(
'添加',
style: TextStyle(
fontSize: 12,
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
@@ -211,7 +230,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
],
),
const SizedBox(height: 12),
const SizedBox(height: 16),
// 图片网格4列正方形铺满
Expanded(
@@ -220,17 +239,33 @@ class _NoteFormPageState extends State<NoteFormPage> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_outlined,
size: 48,
color: const Color(0xFFCCCCCC),
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.image_outlined,
size: 40,
color: Color(0xFFCCCCCC),
),
),
const SizedBox(height: 16),
const Text(
'暂无图片',
style: TextStyle(
fontSize: 15,
color: Color(0xFF999999),
),
),
const SizedBox(height: 8),
const Text(
'点击添加按钮添加图片',
'点击右上角添加按钮添加图片',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
color: Color(0xFFBBBBBB),
),
),
],
@@ -239,8 +274,8 @@ class _NoteFormPageState extends State<NoteFormPage> {
: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.0,
),
itemCount: _images.length,
@@ -264,30 +299,33 @@ class _NoteFormPageState extends State<NoteFormPage> {
return GestureDetector(
onTap: () => _showFormatSelector(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_contentType == 'markdown' ? Icons.code : Icons.text_fields,
size: 14,
size: 16,
color: const Color(0xFF666666),
),
const SizedBox(width: 4),
const SizedBox(width: 6),
Text(
_contentType == 'markdown' ? 'Markdown' : '纯文本',
style: const TextStyle(
fontSize: 12,
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF666666),
),
),
const SizedBox(width: 4),
const Icon(
Icons.arrow_drop_down,
size: 16,
size: 18,
color: Color(0xFF999999),
),
],
@@ -301,36 +339,132 @@ class _NoteFormPageState extends State<NoteFormPage> {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.code, size: 20),
title: const Text('Markdown'),
subtitle: const Text('支持 Markdown 语法'),
trailing: _contentType == 'markdown'
? const Icon(Icons.check, color: Color(0xFF1A1A1A))
: null,
// 顶部指示条
Container(
margin: const EdgeInsets.only(top: 12, bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFE0E0E0),
borderRadius: BorderRadius.circular(2),
),
),
// 标题
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
children: [
Text(
'选择格式',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
),
const SizedBox(height: 8),
_buildFormatOption(
icon: Icons.code,
title: 'Markdown',
subtitle: '支持 Markdown 语法',
isSelected: _contentType == 'markdown',
onTap: () {
setState(() => _contentType = 'markdown');
Navigator.pop(context);
},
),
const Divider(height: 0.5, indent: 56),
ListTile(
leading: const Icon(Icons.text_fields, size: 20),
title: const Text('纯文本'),
subtitle: const Text('普通文本格式,支持图片'),
trailing: _contentType == 'plain_text'
? const Icon(Icons.check, color: Color(0xFF1A1A1A))
: null,
_buildFormatOption(
icon: Icons.text_fields,
title: '纯文本',
subtitle: '普通文本格式,支持图片',
isSelected: _contentType == 'plain_text',
onTap: () {
setState(() => _contentType = 'plain_text');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
/// 构建格式选项
Widget _buildFormatOption({
required IconData icon,
required String title,
required String subtitle,
required bool isSelected,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
size: 22,
color: const Color(0xFF666666),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
],
),
),
if (isSelected)
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.check,
size: 16,
color: Colors.white,
),
),
],
),
),
@@ -340,16 +474,17 @@ class _NoteFormPageState extends State<NoteFormPage> {
/// 构建标签选择器
Widget _buildTagSelector() {
return Wrap(
spacing: 8,
runSpacing: 4,
spacing: 10,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
..._tags.asMap().entries.map((entry) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -357,17 +492,24 @@ class _NoteFormPageState extends State<NoteFormPage> {
Text(
entry.value,
style: const TextStyle(
fontSize: 12,
fontSize: 13,
color: Color(0xFF666666),
),
),
const SizedBox(width: 4),
const SizedBox(width: 6),
GestureDetector(
onTap: () => setState(() => _tags.removeAt(entry.key)),
child: const Icon(
Icons.close,
size: 12,
color: Color(0xFF999999),
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: const Color(0xFFE8E8E8),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.close,
size: 10,
color: Color(0xFF999999),
),
),
),
],
@@ -378,23 +520,25 @@ class _NoteFormPageState extends State<NoteFormPage> {
GestureDetector(
onTap: () => _showAddTagDialog(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.add,
size: 12,
size: 14,
color: Color(0xFF999999),
),
SizedBox(width: 2),
SizedBox(width: 4),
Text(
'标签',
style: TextStyle(
fontSize: 12,
fontSize: 13,
color: Color(0xFF999999),
),
),
@@ -421,11 +565,11 @@ class _NoteFormPageState extends State<NoteFormPage> {
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'添加标签',
style: TextStyle(
fontSize: 16,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
@@ -439,9 +583,27 @@ class _NoteFormPageState extends State<NoteFormPage> {
TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
decoration: InputDecoration(
hintText: '输入新标签名称',
border: UnderlineInputBorder(),
hintStyle: const TextStyle(
fontSize: 14,
color: Color(0xFF999999),
),
filled: true,
fillColor: const Color(0xFFFAFAFA),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
onSubmitted: (value) {
_addTag(value);
@@ -451,18 +613,18 @@ class _NoteFormPageState extends State<NoteFormPage> {
// 已有标签列表
if (availableTags.isNotEmpty) ...[
const SizedBox(height: 16),
const SizedBox(height: 20),
const Text(
'或选择已有标签:',
style: TextStyle(
fontSize: 12,
fontSize: 13,
color: Color(0xFF999999),
),
),
const SizedBox(height: 8),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
spacing: 10,
runSpacing: 10,
children: availableTags.map((tag) {
return GestureDetector(
onTap: () {
@@ -470,16 +632,16 @@ class _NoteFormPageState extends State<NoteFormPage> {
Navigator.pop(context);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
borderRadius: BorderRadius.circular(4),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Text(
tag,
style: const TextStyle(
fontSize: 13,
fontSize: 14,
color: Color(0xFF666666),
),
),
@@ -494,16 +656,30 @@ class _NoteFormPageState extends State<NoteFormPage> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () {
_addTag(controller.text);
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A1A1A),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('添加'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
@@ -580,6 +756,10 @@ class _NoteFormPageState extends State<NoteFormPage> {
ToastUtil.show(context, _isEditing ? '保存成功' : '添加成功');
// 刷新笔记列表
await context.read<AppProvider>().loadNotes();
if (!mounted) return;
Navigator.pop(context);
}
@@ -666,8 +846,10 @@ class _NoteFormPageState extends State<NoteFormPage> {
onLongPress: () => _showDeleteImageDialog(index),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: Image.file(
File(_images[index]),
fit: BoxFit.cover,
@@ -709,22 +891,49 @@ class _NoteFormPageState extends State<NoteFormPage> {
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: const Text('确定要删除这张图片吗?'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'确认删除',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: const Text(
'确定要删除这张图片吗?此操作不可恢复。',
style: TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () {
setState(() => _images.removeAt(index));
Navigator.pop(context);
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}

View File

@@ -30,6 +30,31 @@ class _NoteTabPageState extends State<NoteTabPage> {
});
}
// 用于检测Provider数据变化
int _lastNotesCount = 0;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 使用watch监听Provider数据变化
final provider = context.watch<AppProvider>();
final allNotes = provider.notes;
// 如果Provider中的笔记数量发生变化且本地列表已加载过数据
if (allNotes.length != _lastNotesCount && _displayedNotes.isNotEmpty) {
_lastNotesCount = allNotes.length;
// 清空本地列表并重新加载
setState(() {
_displayedNotes.clear();
_hasMore = true;
});
// 延迟加载避免setState冲突
Future.microtask(() => _loadMoreNotes());
} else {
_lastNotesCount = allNotes.length;
}
}
@override
void dispose() {
_scrollController.dispose();
@@ -98,6 +123,10 @@ class _NoteTabPageState extends State<NoteTabPage> {
builder: (context, provider, child) {
final allNotes = provider.notes;
// 当Provider数据变化时同步更新本地显示列表
// 过滤掉已删除的笔记,保持顺序
_syncDisplayedNotes(allNotes);
if (allNotes.isEmpty && _displayedNotes.isEmpty) {
return _buildEmptyState(context);
}
@@ -135,6 +164,28 @@ class _NoteTabPageState extends State<NoteTabPage> {
);
}
/// 同步显示列表与Provider数据
void _syncDisplayedNotes(List<Note> allNotes) {
// 获取当前所有有效笔记的ID集合
final validNoteIds = allNotes.map((n) => n.id).toSet();
// 移除已删除的笔记不在Provider中的
final initialLength = _displayedNotes.length;
_displayedNotes.removeWhere((note) => !validNoteIds.contains(note.id));
// 检查是否有新增笔记Provider中有但本地列表中没有的
final displayedIds = _displayedNotes.map((n) => n.id).toSet();
final hasNewNotes = allNotes.any((note) => !displayedIds.contains(note.id));
// 如果有笔记被移除或有新增,更新列表
if (_displayedNotes.length < initialLength || hasNewNotes) {
// 清空并重新加载所有数据
_displayedNotes.clear();
_displayedNotes.addAll(allNotes);
_hasMore = false; // 已经有全部数据了
}
}
/// 构建空状态提示
Widget _buildEmptyState(BuildContext context) {
return Center(
@@ -146,7 +197,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.note_outlined,
@@ -154,7 +205,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
color: Color(0xFFCCCCCC),
),
),
const SizedBox(height: 24),
const SizedBox(height: 20),
const Text(
'暂无笔记',
style: TextStyle(
@@ -162,6 +213,14 @@ class _NoteTabPageState extends State<NoteTabPage> {
color: Color(0xFF999999),
),
),
const SizedBox(height: 8),
const Text(
'记录你的想法和灵感',
style: TextStyle(
fontSize: 13,
color: Color(0xFFBBBBBB),
),
),
const SizedBox(height: 24),
InkWell(
onTap: () {
@@ -171,11 +230,13 @@ class _NoteTabPageState extends State<NoteTabPage> {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'添加笔记',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),

View File

@@ -98,30 +98,38 @@ class _ProfilePageState extends State<ProfilePage> {
// 顶部用户信息
_buildUserHeader(),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
const SizedBox(height: 8),
// 数据统计
_buildStatsSection(),
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
const SizedBox(height: 8),
// 功能菜单
_buildMenuSection(),
const SizedBox(height: 48),
const SizedBox(height: 40),
// 版本信息
Center(
child: Text(
'MookNote v$_version',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'MookNote v$_version',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF666666),
),
),
),
),
const SizedBox(height: 24),
const SizedBox(height: 32),
],
),
),
@@ -135,8 +143,9 @@ class _ProfilePageState extends State<ProfilePage> {
return Container(
padding: const EdgeInsets.all(24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 头像
// 左侧头像
GestureDetector(
onTap: _pickAvatar,
child: Container(
@@ -161,35 +170,28 @@ class _ProfilePageState extends State<ProfilePage> {
const SizedBox(width: 20),
// 昵称和座右铭
// 右侧信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 昵称
GestureDetector(
onTap: () => _editNickname(context),
child: Row(
children: [
Text(
_nickname,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 8),
const Icon(
Icons.edit_outlined,
size: 16,
color: Color(0xFF999999),
),
],
child: Text(
_nickname,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
),
const SizedBox(height: 8),
const SizedBox(height: 6),
// 座右铭
GestureDetector(
onTap: () => _editMotto(context),
child: Text(
@@ -198,6 +200,8 @@ class _ProfilePageState extends State<ProfilePage> {
fontSize: 14,
color: Color(0xFF666666),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
@@ -253,56 +257,49 @@ class _ProfilePageState extends State<ProfilePage> {
: 0.0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
// 主统计卡片
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMainStat('观影', movieCount, ''),
const SizedBox(width: 32),
_buildMainStat('阅读', bookCount, ''),
const SizedBox(width: 32),
_buildMainStat('笔记', noteCount, ''),
_buildStatCard('观影', movieCount, '', Icons.movie_outlined),
_buildStatCard('阅读', bookCount, '', Icons.menu_book_outlined),
_buildStatCard('笔记', noteCount, '', Icons.note_outlined),
],
),
),
const SizedBox(height: 32),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('观影详情'),
const SizedBox(height: 16),
Row(
children: [
_buildDetailStat('已看', watchedCount),
_buildDetailStat('在看', watchingCount),
_buildDetailStat('想看', wantToWatchCount),
_buildDetailStat('均分', avgMovieRating.toStringAsFixed(1)),
],
),
const SizedBox(height: 24),
_buildSectionTitle('阅读详情'),
const SizedBox(height: 16),
Row(
children: [
_buildDetailStat('已读', readCount),
_buildDetailStat('在读', readingCount),
_buildDetailStat('想读', wantToReadCount),
_buildDetailStat('均分', avgBookRating.toStringAsFixed(1)),
],
),
],
),
),
// 详情统计
_buildSectionTitle('观影详情'),
const SizedBox(height: 12),
_buildDetailGrid([
_buildDetailItem('已看', watchedCount, Icons.check_circle_outline),
_buildDetailItem('在看', watchingCount, Icons.play_circle_outline),
_buildDetailItem('想看', wantToWatchCount, Icons.bookmark_border),
_buildDetailItem('均分', avgMovieRating > 0 ? avgMovieRating.toStringAsFixed(1) : '-', Icons.star_outline),
]),
const SizedBox(height: 20),
_buildSectionTitle('阅读详情'),
const SizedBox(height: 12),
_buildDetailGrid([
_buildDetailItem('已读', readCount, Icons.check_circle_outline),
_buildDetailItem('在读', readingCount, Icons.play_circle_outline),
_buildDetailItem('想读', wantToReadCount, Icons.bookmark_border),
_buildDetailItem('均分', avgBookRating > 0 ? avgBookRating.toStringAsFixed(1) : '-', Icons.star_outline),
]),
],
),
);
@@ -310,138 +307,166 @@ class _ProfilePageState extends State<ProfilePage> {
);
}
/// 统计
Widget _buildMainStat(String label, int count, String unit) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
/// 统计卡片
Widget _buildStatCard(String label, int count, String unit, IconData icon) {
return Column(
children: [
Icon(
icon,
size: 24,
color: const Color(0xFF666666),
),
const SizedBox(height: 8),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'$count',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'$count',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
const SizedBox(width: 2),
Text(
unit,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
const SizedBox(width: 4),
Text(
unit,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
),
),
],
),
],
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
],
),
],
);
}
/// 详情网格
Widget _buildDetailGrid(List<Widget> children) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: children,
),
);
}
/// 详细统计
Widget _buildDetailStat(String label, dynamic value) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
/// 详
Widget _buildDetailItem(String label, dynamic value, IconData icon) {
return Column(
children: [
Icon(
icon,
size: 18,
color: const Color(0xFF999999),
),
const SizedBox(height: 6),
Text(
'$value',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
const SizedBox(height: 4),
Text(
'$value',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
),
],
),
),
],
);
}
/// 区块标题
Widget _buildSectionTitle(String title) {
return Text(
title.toUpperCase(),
title,
style: const TextStyle(
fontSize: 11,
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF999999),
letterSpacing: 1,
),
);
}
/// 功能菜单
Widget _buildMenuSection() {
return Column(
children: [
_buildMenuItem(
icon: Icons.analytics_outlined,
title: '数据统计',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const StatisticsPage()),
);
},
),
const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)),
_buildMenuItem(
icon: Icons.backup_outlined,
title: '本地备份',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const BackupPage()),
);
},
),
const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)),
_buildMenuItem(
icon: Icons.cloud_sync_outlined,
title: '云备份',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const CloudSyncPage()),
);
},
),
const Divider(height: 0.5, thickness: 0.5, indent: 56, color: Color(0xFFE5E5E5)),
_buildMenuItem(
icon: Icons.delete_outline,
title: '回收站',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const RecycleBinPage()),
);
},
),
],
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_buildMenuItem(
icon: Icons.analytics_outlined,
title: '数据统计',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const StatisticsPage()),
);
},
),
const Divider(height: 1, indent: 72, endIndent: 16, color: Color(0xFFE8E8E8)),
_buildMenuItem(
icon: Icons.backup_outlined,
title: '本地备份',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const BackupPage()),
);
},
),
const Divider(height: 1, indent: 72, endIndent: 16, color: Color(0xFFE8E8E8)),
_buildMenuItem(
icon: Icons.cloud_sync_outlined,
title: '云备份',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const CloudSyncPage()),
);
},
),
const Divider(height: 1, indent: 72, endIndent: 16, color: Color(0xFFE8E8E8)),
_buildMenuItem(
icon: Icons.delete_outline,
title: '回收站',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const RecycleBinPage()),
);
},
),
],
),
);
}
@@ -451,22 +476,49 @@ class _ProfilePageState extends State<ProfilePage> {
required String title,
required VoidCallback onTap,
}) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(icon, size: 22, color: const Color(0xFF666666)),
title: Text(
title,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
// 图标背景
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Icon(
icon,
size: 20,
color: const Color(0xFF666666),
),
),
const SizedBox(width: 16),
// 标题
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
),
// 箭头
const Icon(
Icons.chevron_right,
size: 20,
color: Color(0xFFCCCCCC),
),
],
),
),
trailing: const Icon(
Icons.chevron_right,
size: 20,
color: Color(0xFFCCCCCC),
),
onTap: onTap,
);
}
@@ -674,14 +726,13 @@ class SettingsPage extends StatelessWidget {
/// 构建区块标题
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
padding: const EdgeInsets.fromLTRB(24, 32, 24, 12),
child: Text(
title.toUpperCase(),
title,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Color(0xFF999999),
letterSpacing: 1,
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
);
@@ -694,41 +745,60 @@ class SettingsPage extends StatelessWidget {
required String subtitle,
required VoidCallback onTap,
}) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: const Color(0xFF666666),
size: 20,
),
),
title: Text(
title,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
),
),
subtitle: Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
trailing: const Icon(
Icons.chevron_right,
color: Color(0xFFCCCCCC),
size: 20,
),
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
children: [
// 图标背景
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
color: const Color(0xFF666666),
size: 22,
),
),
const SizedBox(width: 16),
// 文字内容
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
),
),
// 箭头
const Icon(
Icons.chevron_right,
color: Color(0xFFCCCCCC),
size: 20,
),
],
),
),
);
}
@@ -740,41 +810,60 @@ class SettingsPage extends StatelessWidget {
required String subtitle,
required String url,
}) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: const Color(0xFF666666),
size: 20,
),
),
title: Text(
title,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
),
),
subtitle: Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
trailing: const Icon(
Icons.open_in_new,
color: Color(0xFFCCCCCC),
size: 18,
),
return InkWell(
onTap: () => _launchUrl(context, url),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
children: [
// 图标背景
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
color: const Color(0xFF666666),
size: 22,
),
),
const SizedBox(width: 16),
// 文字内容
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
),
),
// 外部链接图标
const Icon(
Icons.open_in_new,
color: Color(0xFFCCCCCC),
size: 18,
),
],
),
),
);
}

View File

@@ -57,6 +57,15 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
labelColor: const Color(0xFF1A1A1A),
unselectedLabelColor: const Color(0xFF999999),
indicatorColor: const Color(0xFF1A1A1A),
indicatorSize: TabBarIndicatorSize.label,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
tabs: [
Tab(text: '影视 (${_deletedMovies.length})'),
Tab(text: '书籍 (${_deletedBooks.length})'),
@@ -65,9 +74,10 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
),
actions: [
// 清空全部
TextButton(
TextButton.icon(
onPressed: _showClearAllDialog,
child: const Text(
icon: const Icon(Icons.delete_sweep, size: 18, color: Colors.red),
label: const Text(
'清空',
style: TextStyle(color: Colors.red),
),
@@ -94,7 +104,7 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
return _buildEmptyState('暂无删除的影视');
}
return ListView.builder(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.only(top: 8),
itemCount: _deletedMovies.length,
itemBuilder: (context, index) {
final movie = _deletedMovies[index];
@@ -114,7 +124,7 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
return _buildEmptyState('暂无删除的书籍');
}
return ListView.builder(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.only(top: 8),
itemCount: _deletedBooks.length,
itemBuilder: (context, index) {
final book = _deletedBooks[index];
@@ -134,7 +144,7 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
return _buildEmptyState('暂无删除的笔记');
}
return ListView.builder(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.only(top: 8),
itemCount: _deletedNotes.length,
itemBuilder: (context, index) {
final note = _deletedNotes[index];
@@ -148,7 +158,7 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
);
}
/// 构建已删除项卡片
/// 构建已删除项卡片 - 紧凑列表布局
Widget _buildDeletedItemCard({
required String title,
required String subtitle,
@@ -156,61 +166,114 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
required VoidCallback onDelete,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
// 左侧图标
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: const Icon(
Icons.delete_outline,
color: Color(0xFF999999),
size: 20,
),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
const SizedBox(width: 12),
// 中间内容区域
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
),
),
// 右侧操作按钮
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 恢复按钮
_buildActionButton(
icon: Icons.restore,
color: const Color(0xFF1A1A1A),
tooltip: '恢复',
onTap: onRestore,
),
const SizedBox(width: 8),
// 彻底删除按钮
_buildActionButton(
icon: Icons.delete_forever,
color: Colors.red,
tooltip: '彻底删除',
onTap: onDelete,
),
],
),
],
),
),
);
}
/// 构建操作按钮
Widget _buildActionButton({
required IconData icon,
required Color color,
required String tooltip,
required VoidCallback onTap,
}) {
return Tooltip(
message: tooltip,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Icon(
icon,
color: color,
size: 18,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 恢复按钮
OutlinedButton(
onPressed: onRestore,
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF1A1A1A),
side: const BorderSide(color: Color(0xFF1A1A1A)),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('恢复'),
),
const SizedBox(width: 12),
// 彻底删除按钮
OutlinedButton(
onPressed: onDelete,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('彻底删除'),
),
],
),
],
),
),
);
}
@@ -220,19 +283,35 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.delete_outline,
size: 64,
color: Color(0xFFCCCCCC),
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.delete_outline,
size: 40,
color: Color(0xFFCCCCCC),
),
),
const SizedBox(height: 16),
const SizedBox(height: 20),
Text(
message,
style: const TextStyle(
fontSize: 16,
fontSize: 15,
color: Color(0xFF999999),
),
),
const SizedBox(height: 8),
const Text(
'删除的项目将显示在这里',
style: TextStyle(
fontSize: 13,
color: Color(0xFFBBBBBB),
),
),
],
),
);
@@ -312,19 +391,46 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: Text(message),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'确认删除',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: Text(
message,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
return result ?? false;
@@ -337,15 +443,38 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('清空回收站'),
content: const Text('确定要清空回收站吗?所有项目将被彻底删除,此操作不可恢复。'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Row(
children: [
Icon(Icons.warning_amber, color: Colors.red, size: 24),
SizedBox(width: 8),
Text(
'清空回收站',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
],
),
content: const Text(
'确定要清空回收站吗?所有项目将被彻底删除,此操作不可恢复。',
style: TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
await context.read<AppProvider>().clearRecycleBin();
@@ -354,9 +483,19 @@ class _RecycleBinPageState extends State<RecycleBinPage> with SingleTickerProvid
ToastUtil.show(context, '回收站已清空');
}
},
child: const Text('清空', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('清空'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}

View File

@@ -105,7 +105,6 @@ class _SearchPageState extends State<SearchPage> {
Widget _buildTagFilter() {
return Consumer<AppProvider>(
builder: (context, provider, child) {
// 获取所有笔记的标签
final allTags = <String>{};
for (final note in provider.notes.where((n) => !n.isDeleted)) {
allTags.addAll(note.tags);
@@ -118,18 +117,37 @@ class _SearchPageState extends State<SearchPage> {
final tags = allTags.toList()..sort();
return Container(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: Row(
children: [
Icon(
Icons.label_outline,
size: 16,
color: Color(0xFF999999),
),
SizedBox(width: 6),
Text(
'按标签筛选',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
],
),
),
Wrap(
spacing: 8,
runSpacing: 8,
spacing: 10,
runSpacing: 10,
alignment: WrapAlignment.start,
children: tags.map((tag) => GestureDetector(
onTap: () {
setState(() {
// 再次点击同一标签则取消筛选
if (_selectedTag == tag) {
_selectedTag = null;
} else {
@@ -139,21 +157,24 @@ class _SearchPageState extends State<SearchPage> {
});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: _selectedTag == tag
? const Color(0xFF1A1A1A)
: const Color(0xFFF5F5F5),
: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _selectedTag == tag
? const Color(0xFF1A1A1A)
: const Color(0xFFE5E5E5),
: const Color(0xFFE8E8E8),
width: 0.5,
),
),
child: Text(
tag,
style: TextStyle(
fontSize: 12,
fontSize: 13,
fontWeight: _selectedTag == tag ? FontWeight.w600 : FontWeight.w500,
color: _selectedTag == tag
? Colors.white
: const Color(0xFF666666),
@@ -175,15 +196,22 @@ class _SearchPageState extends State<SearchPage> {
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('搜索'),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: _performSearch,
),
const SizedBox(width: 8),
],
),
body: Column(
children: [
// 搜索类型选择
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFE5E5E5), width: 0.5),
bottom: BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
),
child: Row(
@@ -194,23 +222,26 @@ class _SearchPageState extends State<SearchPage> {
setState(() {
_selectedType = index;
_results = [];
_selectedTag = null; // 切换类型时重置标签
_selectedTag = null;
});
_searchController.clear();
},
child: Container(
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5),
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE5E5E5),
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE8E8E8),
width: 0.5,
),
),
child: Text(
_typeLabels[index],
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? Colors.white : const Color(0xFF666666),
),
),
@@ -222,14 +253,28 @@ class _SearchPageState extends State<SearchPage> {
// 搜索输入框
Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: _getSearchHint(),
hintStyle: const TextStyle(color: Color(0xFF999999)),
prefixIcon: const Icon(Icons.search, color: Color(0xFF999999)),
hintStyle: const TextStyle(
color: Color(0xFF999999),
fontSize: 14,
),
prefixIcon: Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.search, color: Color(0xFF666666), size: 20),
),
prefixIconConstraints: const BoxConstraints(
minWidth: 48,
minHeight: 48,
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: Color(0xFF999999)),
@@ -239,14 +284,21 @@ class _SearchPageState extends State<SearchPage> {
},
)
: null,
border: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFFE5E5E5)),
filled: true,
fillColor: const Color(0xFFFAFAFA),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFF1A1A1A)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
onSubmitted: (_) => _performSearch(),
onChanged: (_) => setState(() {}),
@@ -283,23 +335,68 @@ class _SearchPageState extends State<SearchPage> {
}
Widget _buildEmptyState() {
if (_searchController.text.isEmpty) {
return const Center(
child: Text(
'输入关键词开始搜索',
style: TextStyle(color: Color(0xFF999999)),
if (_searchController.text.isEmpty && _selectedTag == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.search,
size: 40,
color: Color(0xFFCCCCCC),
),
),
const SizedBox(height: 20),
const Text(
'输入关键词开始搜索',
style: TextStyle(
fontSize: 15,
color: Color(0xFF999999),
),
),
],
),
);
}
return const Center(
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Color(0xFFCCCCCC)),
SizedBox(height: 16),
Text(
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.search_off,
size: 40,
color: Color(0xFFCCCCCC),
),
),
const SizedBox(height: 20),
const Text(
'未找到相关内容',
style: TextStyle(color: Color(0xFF999999)),
style: TextStyle(
fontSize: 15,
color: Color(0xFF999999),
),
),
const SizedBox(height: 8),
Text(
'尝试更换关键词或标签',
style: TextStyle(
fontSize: 13,
color: const Color(0xFFBBBBBB),
),
),
],
),
@@ -308,7 +405,7 @@ class _SearchPageState extends State<SearchPage> {
Widget _buildResultList() {
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
@@ -336,9 +433,11 @@ class _SearchPageState extends State<SearchPage> {
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
children: [
@@ -346,7 +445,11 @@ class _SearchPageState extends State<SearchPage> {
Container(
width: 60,
height: 80,
color: const Color(0xFFF5F5F5),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: movie.posterPath != null
? Image.file(
File(movie.posterPath!),
@@ -355,7 +458,7 @@ class _SearchPageState extends State<SearchPage> {
)
: const Icon(Icons.movie, color: Color(0xFFCCCCCC)),
),
const SizedBox(width: 12),
const SizedBox(width: 16),
// 信息
Expanded(
child: Column(
@@ -366,23 +469,24 @@ class _SearchPageState extends State<SearchPage> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
if (movie.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 4),
const SizedBox(height: 6),
Text(
movie.alternateTitles.join(' / '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontSize: 13,
color: Color(0xFF999999),
),
),
],
const SizedBox(height: 4),
const SizedBox(height: 10),
_buildStatusTag(movie.status),
],
),
@@ -405,9 +509,11 @@ class _SearchPageState extends State<SearchPage> {
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
children: [
@@ -415,7 +521,11 @@ class _SearchPageState extends State<SearchPage> {
Container(
width: 60,
height: 80,
color: const Color(0xFFF5F5F5),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: book.coverPath != null
? Image.file(
File(book.coverPath!),
@@ -424,7 +534,7 @@ class _SearchPageState extends State<SearchPage> {
)
: const Icon(Icons.book, color: Color(0xFFCCCCCC)),
),
const SizedBox(width: 12),
const SizedBox(width: 16),
// 信息
Expanded(
child: Column(
@@ -435,23 +545,24 @@ class _SearchPageState extends State<SearchPage> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
if (book.alternateTitles.isNotEmpty) ...[
const SizedBox(height: 4),
const SizedBox(height: 6),
Text(
book.alternateTitles.join(' / '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontSize: 13,
color: Color(0xFF999999),
),
),
],
const SizedBox(height: 4),
const SizedBox(height: 10),
_buildBookStatusTag(book.status),
],
),
@@ -463,6 +574,8 @@ class _SearchPageState extends State<SearchPage> {
}
Widget _buildNoteItem(Note note) {
final isPlainText = note.contentType == 'plain_text';
return GestureDetector(
onTap: () {
Navigator.push(
@@ -474,30 +587,98 @@ class _SearchPageState extends State<SearchPage> {
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部:格式标记 + 时间
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Text(
isPlainText ? 'TXT' : 'MD',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
),
),
),
const SizedBox(width: 10),
Text(
'${note.createdAt.year}.${note.createdAt.month.toString().padLeft(2, '0')}.${note.createdAt.day.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
const Spacer(),
// 图片数量(如果有图片)
if (note.images.isNotEmpty) ...[
const Icon(
Icons.image_outlined,
size: 14,
color: Color(0xFF999999),
),
const SizedBox(width: 4),
Text(
'${note.images.length}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
],
),
const SizedBox(height: 12),
// 内容
Text(
note.content,
note.summary.trim(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
height: 1.5,
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.6,
),
),
const SizedBox(height: 8),
Text(
'${note.createdAt.year}.${note.createdAt.month.toString().padLeft(2, '0')}.${note.createdAt.day.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
// 标签
if (note.tags.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: note.tags.take(3).map((tag) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Text(
tag,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF666666),
),
),
);
}).toList(),
),
),
],
],
),
),
@@ -506,60 +687,84 @@ class _SearchPageState extends State<SearchPage> {
Widget _buildStatusTag(String status) {
String label;
Color color;
Color bgColor;
Color textColor;
switch (status) {
case 'watched':
label = '已看';
color = const Color(0xFF1A1A1A);
bgColor = const Color(0xFF1A1A1A);
textColor = Colors.white;
break;
case 'watching':
label = '在看';
color = const Color(0xFF666666);
bgColor = const Color(0xFFF0F0F0);
textColor = const Color(0xFF666666);
break;
case 'want_to_watch':
label = '想看';
color = const Color(0xFF999999);
bgColor = const Color(0xFFF5F5F5);
textColor = const Color(0xFF999999);
break;
default:
label = '未知';
color = const Color(0xFFCCCCCC);
bgColor = const Color(0xFFEEEEEE);
textColor = const Color(0xFFCCCCCC);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: color),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: const TextStyle(fontSize: 11, color: Colors.white),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: textColor,
),
),
);
}
Widget _buildBookStatusTag(String status) {
String label;
Color color;
Color bgColor;
Color textColor;
switch (status) {
case 'read':
label = '已读';
color = const Color(0xFF1A1A1A);
bgColor = const Color(0xFF1A1A1A);
textColor = Colors.white;
break;
case 'reading':
label = '在读';
color = const Color(0xFF666666);
bgColor = const Color(0xFFF0F0F0);
textColor = const Color(0xFF666666);
break;
case 'want_to_read':
label = '想读';
color = const Color(0xFF999999);
bgColor = const Color(0xFFF5F5F5);
textColor = const Color(0xFF999999);
break;
default:
label = '未知';
color = const Color(0xFFCCCCCC);
bgColor = const Color(0xFFEEEEEE);
textColor = const Color(0xFFCCCCCC);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: color),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: const TextStyle(fontSize: 11, color: Colors.white),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: textColor,
),
),
);
}

View File

@@ -12,6 +12,7 @@ class StatisticsPage extends StatefulWidget {
}
class _StatisticsPageState extends State<StatisticsPage> {
int _selectedTab = 0;
@override
Widget build(BuildContext context) {
@@ -26,21 +27,74 @@ class _StatisticsPageState extends State<StatisticsPage> {
final books = provider.books;
final notes = provider.notes;
return _buildOverviewTab(movies, books, notes);
return Column(
children: [
// 标签切换栏
_buildTabBar(),
// 内容区域
Expanded(
child: _selectedTab == 0
? _buildOverviewTab(movies, books, notes)
: _buildTrendTab(movies, books, notes),
),
],
);
},
),
);
}
/// 构建标签栏
Widget _buildTabBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
children: [
Expanded(
child: _buildTabItem('概览', 0),
),
const SizedBox(width: 12),
Expanded(
child: _buildTabItem('趋势', 1),
),
],
),
);
}
/// 构建标签项
Widget _buildTabItem(String label, int index) {
final isSelected = _selectedTab == index;
return GestureDetector(
onTap: () => setState(() => _selectedTab = index),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? Colors.white : const Color(0xFF666666),
),
),
),
);
}
/// 概览标签页
Widget _buildOverviewTab(List<Movie> movies, List<Book> books, List<Note> notes) {
final totalMovies = movies.length;
final totalMovies = movies.where((m) => !m.isDeleted).length;
final totalBooks = books.length;
final totalNotes = notes.length;
final watchedMovies = movies.where((m) => m.status == 'watched').length;
final watchingMovies = movies.where((m) => m.status == 'watching').length;
final wantToWatchMovies = movies.where((m) => m.status == 'want_to_watch').length;
final watchedMovies = movies.where((m) => m.status == 'watched' && !m.isDeleted).length;
final watchingMovies = movies.where((m) => m.status == 'watching' && !m.isDeleted).length;
final wantToWatchMovies = movies.where((m) => m.status == 'want_to_watch' && !m.isDeleted).length;
final readBooks = books.where((b) => b.status == 'read').length;
final readingBooks = books.where((b) => b.status == 'reading').length;
@@ -52,14 +106,19 @@ class _StatisticsPageState extends State<StatisticsPage> {
// 总数据卡片
_buildSectionTitle('数据总览'),
const SizedBox(height: 16),
Row(
children: [
Expanded(child: _buildStatCard('影视', totalMovies, Icons.movie_outlined, const Color(0xFF1A1A1A))),
const SizedBox(width: 12),
Expanded(child: _buildStatCard('书籍', totalBooks, Icons.menu_book_outlined, const Color(0xFF666666))),
const SizedBox(width: 12),
Expanded(child: _buildStatCard('笔记', totalNotes, Icons.note_outlined, const Color(0xFF999999))),
],
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(child: _buildStatItem('影视', totalMovies, Icons.movie_outlined)),
Expanded(child: _buildStatItem('书籍', totalBooks, Icons.menu_book_outlined)),
Expanded(child: _buildStatItem('笔记', totalNotes, Icons.note_outlined)),
],
),
),
const SizedBox(height: 32),
@@ -91,33 +150,179 @@ class _StatisticsPageState extends State<StatisticsPage> {
);
}
/// 构建统计卡片
Widget _buildStatCard(String title, int count, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
count.toString(),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: color,
),
/// 趋势标签页
Widget _buildTrendTab(List<Movie> movies, List<Book> books, List<Note> notes) {
final now = DateTime.now();
final thirtyDaysAgo = now.subtract(const Duration(days: 30));
// 生成最近30天的日期列表
final dates = List.generate(30, (index) {
return now.subtract(Duration(days: 29 - index));
});
// 计算每天的新增数量
final movieTrend = dates.map((date) {
return movies.where((m) {
final created = m.createdAt;
return created.year == date.year &&
created.month == date.month &&
created.day == date.day &&
!m.isDeleted;
}).length;
}).toList();
final bookTrend = dates.map((date) {
return books.where((b) {
final created = b.createdAt;
return created.year == date.year &&
created.month == date.month &&
created.day == date.day;
}).length;
}).toList();
final noteTrend = dates.map((date) {
return notes.where((n) {
final created = n.createdAt;
return created.year == date.year &&
created.month == date.month &&
created.day == date.day;
}).length;
}).toList();
// 计算总数
final totalMovies = movieTrend.reduce((a, b) => a + b);
final totalBooks = bookTrend.reduce((a, b) => a + b);
final totalNotes = noteTrend.reduce((a, b) => a + b);
return ListView(
padding: const EdgeInsets.all(24),
children: [
// 30天统计摘要
_buildSectionTitle('近30天新增'),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(12),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(fontSize: 12, color: Color(0xFF666666)),
child: Row(
children: [
Expanded(child: _buildTrendSummary('影视', totalMovies)),
Expanded(child: _buildTrendSummary('书籍', totalBooks)),
Expanded(child: _buildTrendSummary('笔记', totalNotes)),
],
),
],
),
),
const SizedBox(height: 32),
// 趋势说明
_buildSectionTitle('数据趋势'),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_buildTrendRow('影视', movieTrend, const Color(0xFF1A1A1A)),
const SizedBox(height: 16),
_buildTrendRow('书籍', bookTrend, const Color(0xFF666666)),
const SizedBox(height: 16),
_buildTrendRow('笔记', noteTrend, const Color(0xFF999999)),
],
),
),
],
);
}
/// 构建趋势摘要项
Widget _buildTrendSummary(String label, int count) {
return Column(
children: [
Text(
count.toString(),
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
),
],
);
}
/// 构建趋势行
Widget _buildTrendRow(String label, List<int> data, Color color) {
final maxValue = data.isEmpty ? 1 : data.reduce((a, b) => a > b ? a : b);
final safeMax = maxValue == 0 ? 1 : maxValue;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: color,
),
),
const SizedBox(height: 8),
SizedBox(
height: 40,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: data.map((value) {
final height = value / safeMax * 40;
return Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
height: height < 2 ? 2 : height,
decoration: BoxDecoration(
color: color.withOpacity(value == 0 ? 0.1 : 0.6),
borderRadius: BorderRadius.circular(2),
),
),
);
}).toList(),
),
),
],
);
}
/// 构建统计项
Widget _buildStatItem(String title, int count, IconData icon) {
return Column(
children: [
Icon(icon, size: 24, color: const Color(0xFF666666)),
const SizedBox(height: 8),
Text(
count.toString(),
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(fontSize: 13, color: Color(0xFF666666)),
),
],
);
}
@@ -125,29 +330,57 @@ class _StatisticsPageState extends State<StatisticsPage> {
Widget _buildStatusDistribution(List<_StatusData> data) {
final total = data.fold(0, (sum, item) => sum + item.count);
return Column(
children: data.map((item) {
final percentage = total > 0 ? (item.count / total * 100).toStringAsFixed(1) : '0';
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: item.color),
),
const SizedBox(width: 12),
Text(item.label, style: const TextStyle(fontSize: 14)),
const Spacer(),
Text(
'${item.count} ($percentage%)',
style: const TextStyle(fontSize: 14, color: Color(0xFF666666)),
),
],
),
);
}).toList(),
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: data.map((item) {
final percentage = total > 0 ? (item.count / total * 100).toStringAsFixed(1) : '0';
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: item.color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
Text(
item.label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1A1A1A),
),
),
const Spacer(),
Text(
'${item.count}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 8),
Text(
'($percentage%)',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
],
),
);
}).toList(),
),
);
}
@@ -156,21 +389,22 @@ class _StatisticsPageState extends State<StatisticsPage> {
final now = DateTime.now();
final sevenDaysAgo = now.subtract(const Duration(days: 7));
final recentMovies = movies.where((m) => m.createdAt.isAfter(sevenDaysAgo)).length;
final recentMovies = movies.where((m) => m.createdAt.isAfter(sevenDaysAgo) && !m.isDeleted).length;
final recentBooks = books.where((b) => b.createdAt.isAfter(sevenDaysAgo)).length;
final recentNotes = notes.where((n) => n.createdAt.isAfter(sevenDaysAgo)).length;
return Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_buildActivityRow('新增影视', recentMovies, Icons.movie_outlined),
const Divider(height: 24),
const Divider(height: 20, color: Color(0xFFE8E8E8)),
_buildActivityRow('新增书籍', recentBooks, Icons.menu_book_outlined),
const Divider(height: 24),
const Divider(height: 20, color: Color(0xFFE8E8E8)),
_buildActivityRow('新增笔记', recentNotes, Icons.note_outlined),
],
),
@@ -180,26 +414,66 @@ class _StatisticsPageState extends State<StatisticsPage> {
Widget _buildActivityRow(String label, int count, IconData icon) {
return Row(
children: [
Icon(icon, size: 20, color: const Color(0xFF666666)),
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Icon(icon, size: 18, color: const Color(0xFF666666)),
),
const SizedBox(width: 12),
Text(label, style: const TextStyle(fontSize: 14)),
Text(
label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1A1A1A),
),
),
const Spacer(),
Text(
'$count',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
'$count',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 4),
const Text(
'',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
],
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
return Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
);
}
}

View File

@@ -50,13 +50,12 @@ class _BackupPageState extends State<BackupPage> {
: ListView(
padding: const EdgeInsets.all(24),
children: [
// 自动备份开关
_buildAutoBackupSection(),
const SizedBox(height: 32),
// 数据操作区域
_buildSectionTitle('手动备份'),
const SizedBox(height: 16),
// 导出数据
_buildSection(
_buildActionCard(
title: '导出数据',
description: '将所有数据导出为 zip 文件,可用于备份或迁移到其他设备',
icon: Icons.upload_outlined,
@@ -65,10 +64,10 @@ class _BackupPageState extends State<BackupPage> {
onTap: _exportData,
),
const SizedBox(height: 32),
const SizedBox(height: 16),
// 导入数据
_buildSection(
_buildActionCard(
title: '导入数据',
description: '从备份文件导入数据,将覆盖当前所有数据',
icon: Icons.download_outlined,
@@ -78,57 +77,47 @@ class _BackupPageState extends State<BackupPage> {
isDestructive: true,
),
const SizedBox(height: 48),
const SizedBox(height: 32),
// 说明
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: const Color(0xFF666666),
),
const SizedBox(width: 8),
Text(
'使用说明',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: const Color(0xFF666666),
),
),
],
),
const SizedBox(height: 12),
Text(
'1. 导出数据会生成一个 .zip 文件,包含所有数据和图片\n'
'2. 选择保存路径后,可以通过微信、邮件等方式发送备份文件\n'
'3. 在新设备上选择导入数据,选择备份文件即可恢复\n'
'4. 导入数据会完全覆盖当前设备的数据,请谨慎操作',
style: TextStyle(
fontSize: 13,
color: const Color(0xFF999999),
height: 1.6,
),
),
],
),
),
// 自动备份开关
_buildAutoBackupSection(),
const SizedBox(height: 32),
// 使用说明
_buildInfoSection(),
],
),
);
}
Widget _buildSection({
/// 构建区块标题
Widget _buildSectionTitle(String title) {
return Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
);
}
/// 构建操作卡片
Widget _buildActionCard({
required String title,
required String description,
required IconData icon,
@@ -138,69 +127,187 @@ class _BackupPageState extends State<BackupPage> {
bool isDestructive = false,
}) {
return Container(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
size: 24,
color: isDestructive ? Colors.red : const Color(0xFF1A1A1A),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDestructive
? Colors.red.withOpacity(0.3)
: const Color(0xFFE8E8E8),
width: 0.5,
),
),
child: Icon(
icon,
size: 22,
color: isDestructive ? Colors.red : const Color(0xFF666666),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 4),
Text(
description,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
height: 1.4,
),
),
],
),
),
],
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isLoading ? null : onTap,
style: ElevatedButton.styleFrom(
backgroundColor: isDestructive
? Colors.red
: const Color(0xFF1A1A1A),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
)
: Text(
buttonText,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
/// 构建信息说明区域
Widget _buildInfoSection() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: const Icon(
Icons.info_outline,
size: 18,
color: Color(0xFF666666),
),
),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
const Text(
'使用说明',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 16),
_buildInfoItem('1', '导出数据会生成一个 .zip 文件,包含所有数据和图片'),
const SizedBox(height: 12),
Text(
description,
_buildInfoItem('2', '选择保存路径后,可以通过微信、邮件等方式发送备份文件'),
const SizedBox(height: 12),
_buildInfoItem('3', '在新设备上选择导入数据,选择备份文件即可恢复'),
const SizedBox(height: 12),
_buildInfoItem('4', '导入数据会完全覆盖当前设备的数据,请谨慎操作'),
],
),
);
}
/// 构建信息项
Widget _buildInfoItem(String number, String text) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: const Color(0xFFE8E8E8),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
number,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF666666),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 14,
fontSize: 13,
color: Color(0xFF666666),
height: 1.5,
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: isLoading ? null : onTap,
style: OutlinedButton.styleFrom(
foregroundColor: isDestructive ? Colors.red : const Color(0xFF1A1A1A),
side: BorderSide(
color: isDestructive ? Colors.red : const Color(0xFF1A1A1A),
),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(
isDestructive ? Colors.red : const Color(0xFF1A1A1A),
),
),
)
: Text(buttonText),
),
),
],
),
),
],
);
}
@@ -332,27 +439,51 @@ class _BackupPageState extends State<BackupPage> {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.schedule,
size: 24,
color: Color(0xFF1A1A1A),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: const Icon(
Icons.schedule,
size: 22,
color: Color(0xFF666666),
),
),
const SizedBox(width: 12),
const SizedBox(width: 16),
const Expanded(
child: Text(
'自动本地备份',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'自动本地备份',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
SizedBox(height: 4),
Text(
'每2分钟自动备份保留最近10个备份',
style: TextStyle(
fontSize: 13,
color: Color(0xFF666666),
),
),
],
),
),
Switch(
@@ -361,33 +492,48 @@ class _BackupPageState extends State<BackupPage> {
setState(() => _autoBackupEnabled = value);
await AutoBackupService.instance.setEnabled(value);
if (value) {
ToastUtil.show(context, '自动备份已开启每2分钟备份一次');
ToastUtil.show(context, '自动备份已开启');
} else {
ToastUtil.show(context, '自动备份已关闭');
}
// 刷新文件列表
await _loadAutoBackupStatus();
},
activeColor: const Color(0xFF1A1A1A),
activeTrackColor: const Color(0xFF1A1A1A).withOpacity(0.3),
inactiveThumbColor: Colors.white,
inactiveTrackColor: const Color(0xFFE5E5E5),
),
],
),
const SizedBox(height: 8),
Text(
'每隔2分钟自动备份到下载目录/mooknote文件夹最多保留10个备份文件',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
height: 1.5,
),
),
if (_backupDirPath != null) ...[
const SizedBox(height: 8),
Text(
'备份位置: $_backupDirPath',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
if (_backupDirPath != null && _autoBackupEnabled) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Row(
children: [
const Icon(
Icons.folder_outlined,
size: 16,
color: Color(0xFF999999),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_backupDirPath!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF666666),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],

View File

@@ -13,8 +13,12 @@ class CloudSyncPage extends StatelessWidget {
title: const Text('云备份'),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(24),
children: [
// 备份方式标题
_buildSectionTitle('选择备份方式'),
const SizedBox(height: 16),
// WebDAV 备份选项
_buildSyncOption(
context,
@@ -46,43 +50,117 @@ class CloudSyncPage extends StatelessWidget {
const SizedBox(height: 32),
// 说明文字
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'关于云备份',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
SizedBox(height: 8),
Text(
'• 云备份可以将您的数据备份到远程服务器\n'
'• 支持多台设备之间的数据恢复\n'
'• 建议定期进行云备份以确保数据安全\n'
'• 首次备份可能需要较长时间,请保持网络连接',
style: TextStyle(
fontSize: 13,
color: Color(0xFF666666),
height: 1.6,
),
),
],
),
),
_buildInfoSection(),
],
),
);
}
/// 构建区块标题
Widget _buildSectionTitle(String title) {
return Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
);
}
/// 构建信息说明区域
Widget _buildInfoSection() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: const Icon(
Icons.info_outline,
size: 18,
color: Color(0xFF666666),
),
),
const SizedBox(width: 12),
const Text(
'关于云备份',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 16),
_buildInfoItem('云备份可以将您的数据备份到远程服务器'),
const SizedBox(height: 10),
_buildInfoItem('支持多台设备之间的数据恢复'),
const SizedBox(height: 10),
_buildInfoItem('建议定期进行云备份以确保数据安全'),
const SizedBox(height: 10),
_buildInfoItem('首次备份可能需要较长时间,请保持网络连接'),
],
),
);
}
/// 构建信息项
Widget _buildInfoItem(String text) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(top: 7),
decoration: BoxDecoration(
color: const Color(0xFF999999),
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
height: 1.5,
),
),
),
],
);
}
Widget _buildSyncOption(
BuildContext context, {
required IconData icon,
@@ -94,12 +172,14 @@ class CloudSyncPage extends StatelessWidget {
return GestureDetector(
onTap: enabled ? onTap : null,
child: Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: enabled ? const Color(0xFFFAFAFA) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: enabled ? const Color(0xFFE5E5E5) : const Color(0xFFEEEEEE),
color: enabled ? const Color(0xFFE8E8E8) : const Color(0xFFEEEEEE),
width: 0.5,
),
color: enabled ? Colors.white : const Color(0xFFF5F5F5),
),
child: Row(
children: [
@@ -107,16 +187,19 @@ class CloudSyncPage extends StatelessWidget {
width: 48,
height: 48,
decoration: BoxDecoration(
color: enabled
? const Color(0xFFF5F5F5)
: const Color(0xFFEEEEEE),
color: enabled ? Colors.white : const Color(0xFFEEEEEE),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: enabled ? const Color(0xFFE8E8E8) : const Color(0xFFEEEEEE),
width: 0.5,
),
),
child: Icon(
icon,
color: enabled
? const Color(0xFF1A1A1A)
? const Color(0xFF666666)
: const Color(0xFF999999),
size: 24,
size: 22,
),
),
const SizedBox(width: 16),
@@ -128,7 +211,7 @@ class CloudSyncPage extends StatelessWidget {
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
color: enabled
? const Color(0xFF1A1A1A)
: const Color(0xFF999999),
@@ -142,6 +225,7 @@ class CloudSyncPage extends StatelessWidget {
color: enabled
? const Color(0xFF666666)
: const Color(0xFF999999),
height: 1.4,
),
),
],
@@ -150,8 +234,8 @@ class CloudSyncPage extends StatelessWidget {
Icon(
Icons.chevron_right,
color: enabled
? const Color(0xFF999999)
: const Color(0xFFCCCCCC),
? const Color(0xFFCCCCCC)
: const Color(0xFFE5E5E5),
),
],
),

View File

@@ -228,7 +228,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -236,74 +236,115 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
if (_isConfigured)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: const BoxDecoration(
color: Color(0xFFF5F5F5),
border: Border(
left: BorderSide(color: Color(0xFF1A1A1A), width: 4),
),
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(12),
),
child: const Row(
child: Row(
children: [
Icon(Icons.check_circle, color: Color(0xFF1A1A1A), size: 20),
SizedBox(width: 8),
Text(
'已配置 WebDAV',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.check_circle,
color: Color(0xFF1A1A1A),
size: 20,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'已配置 WebDAV',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
SizedBox(height: 2),
Text(
'配置已保存,可以进行数据同步',
style: TextStyle(
fontSize: 12,
color: Color(0xFF666666),
),
),
],
),
),
],
),
),
// 服务器地址
_buildTextField(
controller: _urlController,
label: '服务器地址',
hint: 'https://dav.example.com 或 http://192.168.1.1:5244',
icon: Icons.link,
),
// 配置表单
_buildSectionTitle('服务器配置'),
const SizedBox(height: 16),
// 用户名
_buildTextField(
controller: _usernameController,
label: '用户名',
hint: '请输入用户名',
icon: Icons.person_outline,
),
const SizedBox(height: 16),
// 密码
_buildTextField(
controller: _passwordController,
label: '密码',
hint: '请输入密码',
icon: Icons.lock_outline,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
color: const Color(0xFF999999),
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
// 服务器地址
_buildTextField(
controller: _urlController,
label: '服务器地址',
hint: 'https://dav.example.com',
icon: Icons.link,
),
const SizedBox(height: 16),
// 用户名
_buildTextField(
controller: _usernameController,
label: '用户名',
hint: '请输入用户名',
icon: Icons.person_outline,
),
const SizedBox(height: 16),
// 密码
_buildTextField(
controller: _passwordController,
label: '密码',
hint: '请输入密码',
icon: Icons.lock_outline,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
color: const Color(0xFF999999),
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
const SizedBox(height: 16),
// 同步路径
_buildTextField(
controller: _pathController,
label: '同步路径',
hint: '/mooknote',
icon: Icons.folder_outlined,
),
],
),
),
const SizedBox(height: 16),
// 同步路径
_buildTextField(
controller: _pathController,
label: '同步路径',
hint: '/mooknote',
icon: Icons.folder_outlined,
),
const SizedBox(height: 24),
// 保存配置按钮
@@ -315,82 +356,118 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
backgroundColor: const Color(0xFF1A1A1A),
foregroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text(
'测试并保存',
style: TextStyle(fontSize: 16),
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
),
if (_isConfigured) ...[
const SizedBox(height: 24),
// 同步方向选择
const Text(
'同步方向',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF666666),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _buildDirectionButton(
'上传',
SyncDirection.upload,
Icons.upload,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildDirectionButton(
'下载',
SyncDirection.download,
Icons.download,
),
),
],
),
const SizedBox(height: 32),
// 同步操作区域
_buildSectionTitle('数据同步'),
const SizedBox(height: 16),
// 同步按钮
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _syncData,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1A1A1A),
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
side: const BorderSide(color: Color(0xFF1A1A1A)),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text(
'立即同步',
style: TextStyle(fontSize: 16),
),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'同步方向',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildDirectionButton(
'上传到云端',
SyncDirection.upload,
Icons.upload,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDirectionButton(
'下载到本地',
SyncDirection.download,
Icons.download,
),
),
],
),
const SizedBox(height: 20),
// 同步按钮
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _syncData,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A1A1A),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text(
'立即同步',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
const SizedBox(height: 16),
// 清除配置按钮
SizedBox(
width: double.infinity,
child: TextButton(
child: OutlinedButton(
onPressed: _isLoading ? null : _clearConfig,
style: TextButton.styleFrom(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('清除配置'),
child: const Text(
'清除配置',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
),
],
@@ -398,44 +475,117 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
const SizedBox(height: 32),
// 说明
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'使用说明',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
SizedBox(height: 8),
Text(
'• 支持坚果云、Nextcloud、AList 等 WebDAV 服务\n'
'• 服务器地址需包含协议http:// 或 https://\n'
'• 同步前请确保服务器可用且空间充足\n'
'• 首次同步将上传所有数据,后续只同步变更',
style: TextStyle(
fontSize: 13,
color: Color(0xFF666666),
height: 1.6,
),
),
],
),
),
_buildInfoSection(),
],
),
),
);
}
/// 构建区块标题
Widget _buildSectionTitle(String title) {
return Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
);
}
/// 构建信息说明区域
Widget _buildInfoSection() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: const Icon(
Icons.info_outline,
size: 18,
color: Color(0xFF666666),
),
),
const SizedBox(width: 12),
const Text(
'使用说明',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 16),
_buildInfoItem('支持坚果云、Nextcloud、AList 等 WebDAV 服务'),
const SizedBox(height: 10),
_buildInfoItem('服务器地址需包含协议http:// 或 https://'),
const SizedBox(height: 10),
_buildInfoItem('同步前请确保服务器可用且空间充足'),
const SizedBox(height: 10),
_buildInfoItem('首次同步将上传所有数据,后续只同步变更'),
],
),
);
}
/// 构建信息项
Widget _buildInfoItem(String text) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(top: 7),
decoration: BoxDecoration(
color: const Color(0xFF999999),
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
height: 1.5,
),
),
),
],
);
}
Widget _buildTextField({
required TextEditingController controller,
@@ -451,9 +601,9 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
Text(
label,
style: const TextStyle(
fontSize: 13,
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF666666),
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 8),
@@ -462,20 +612,22 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
obscureText: obscureText,
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFFCCCCCC)),
hintStyle: const TextStyle(color: Color(0xFFAAAAAA)),
prefixIcon: Icon(icon, color: const Color(0xFF999999)),
suffixIcon: suffixIcon,
border: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFFE5E5E5)),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE8E8E8)),
),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFFE5E5E5)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE8E8E8)),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Color(0xFF1A1A1A)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF1A1A1A)),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
@@ -489,26 +641,29 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
return GestureDetector(
onTap: () => setState(() => _syncDirection = direction),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5),
color: isSelected ? const Color(0xFF1A1A1A) : Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE5E5E5),
color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE8E8E8),
width: 0.5,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 20,
size: 18,
color: isSelected ? Colors.white : const Color(0xFF666666),
),
const SizedBox(height: 4),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 12,
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : const Color(0xFF666666),
),
),

View File

@@ -31,7 +31,7 @@ class DatabaseHelper {
return await openDatabase(
path,
version: 9,
version: 11,
onCreate: _createDB,
onUpgrade: _onUpgrade,
);
@@ -74,6 +74,40 @@ class DatabaseHelper {
// 为笔记表添加图片字段
await _upgradeNotesTableV9(db);
}
if (oldVersion < 10) {
// 为影视表添加观看日期字段
await _upgradeMoviesTableV10(db);
}
if (oldVersion < 11) {
// 为书籍表添加ISBN和出版时间字段
await _upgradeBooksTableV11(db);
}
}
/// 升级books表到V11添加ISBN和出版时间字段
Future<void> _upgradeBooksTableV11(Database db) async {
// 检查是否存在 isbn 列
final columns = await db.rawQuery('PRAGMA table_info(books)');
final hasIsbn = columns.any((col) => col['name'] == 'isbn');
final hasPublishDate = columns.any((col) => col['name'] == 'publish_date');
if (!hasIsbn) {
await db.execute('ALTER TABLE books ADD COLUMN isbn TEXT');
}
if (!hasPublishDate) {
await db.execute('ALTER TABLE books ADD COLUMN publish_date TEXT');
}
}
/// 升级movies表到V10添加观看日期字段
Future<void> _upgradeMoviesTableV10(Database db) async {
// 检查是否存在 watch_date 列
final columns = await db.rawQuery('PRAGMA table_info(movies)');
final hasWatchDate = columns.any((col) => col['name'] == 'watch_date');
if (!hasWatchDate) {
await db.execute('ALTER TABLE movies ADD COLUMN watch_date TEXT');
}
}
/// 升级notes表到V9添加图片字段
@@ -338,6 +372,7 @@ class DatabaseHelper {
summary TEXT,
rating REAL,
status TEXT NOT NULL,
watch_date TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0
@@ -357,6 +392,8 @@ class DatabaseHelper {
summary TEXT,
rating REAL,
status TEXT NOT NULL,
isbn TEXT,
publish_date TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0

View File

@@ -11,7 +11,7 @@ class CustomBottomNavBar extends StatelessWidget {
return Consumer<AppProvider>(
builder: (context, provider, child) {
return Container(
height: 48,
height: 64,
decoration: const BoxDecoration(
color: Colors.white,
border: Border(
@@ -58,12 +58,19 @@ class CustomBottomNavBar extends StatelessWidget {
onTap: onTap,
child: Container(
color: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.symmetric(vertical: 10),
child: Center(
child: Icon(
isActive ? activeIcon : icon,
color: isActive ? const Color(0xFF1A1A1A) : const Color(0xFF999999),
size: 24,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF1A1A1A) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isActive ? activeIcon : icon,
color: isActive ? Colors.white : const Color(0xFF999999),
size: 22,
),
),
),
),
@@ -73,14 +80,15 @@ class CustomBottomNavBar extends StatelessWidget {
/// 构建中间新增按钮
Widget _buildAddButton(BuildContext context, AppProvider provider) {
return InkWell(
return GestureDetector(
onTap: () => _showAddDialog(context, provider),
onLongPress: () => _showQuickAddDialog(context, provider),
child: Container(
width: 40,
height: 40,
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.add,
@@ -91,68 +99,202 @@ class CustomBottomNavBar extends StatelessWidget {
);
}
/// 长按快速添加 - 根据当前界面直接跳转到对应添加界面
void _showQuickAddDialog(BuildContext context, AppProvider provider) {
// 根据当前主标签页决定跳转到哪个添加界面
final currentTab = provider.mainTabIndex;
switch (currentTab) {
case 0: // 观影标签页
final statusMap = {
0: 'watched',
1: 'watching',
2: 'want_to_watch',
};
final currentStatus = statusMap[provider.movieStatusIndex] ?? 'want_to_watch';
Navigator.pushNamed(
context,
'/movie-form',
arguments: {'initialStatus': currentStatus},
);
break;
case 1: // 阅读标签页
final statusMap = {
0: 'read',
1: 'reading',
2: 'want_to_read',
};
final currentStatus = statusMap[provider.bookStatusIndex] ?? 'want_to_read';
Navigator.pushNamed(
context,
'/book-form',
arguments: {'initialStatus': currentStatus},
);
break;
case 2: // 笔记标签页
Navigator.pushNamed(context, '/note-form');
break;
default:
_showAddDialog(context, provider);
}
}
/// 显示新增对话框
void _showAddDialog(BuildContext context, AppProvider provider) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (BuildContext context) {
return SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.movie, color: Colors.green),
title: const Text('添加观影'),
subtitle: const Text('记录你看过的电影'),
onTap: () {
Navigator.pop(context);
// 根据当前影视标签页的选中状态设置默认值
final statusMap = {
0: 'watched',
1: 'watching',
2: 'want_to_watch',
};
final currentStatus = statusMap[provider.movieStatusIndex] ?? 'want_to_watch';
Navigator.pushNamed(
context,
'/movie-form',
arguments: {'initialStatus': currentStatus},
);
},
),
ListTile(
leading: const Icon(Icons.menu_book, color: Colors.orange),
title: const Text('添加阅读'),
subtitle: const Text('记录你读过的书'),
onTap: () {
Navigator.pop(context);
// 根据当前阅读标签页的选中状态设置默认值
final statusMap = {
0: 'read',
1: 'reading',
2: 'want_to_read',
};
final currentStatus = statusMap[provider.bookStatusIndex] ?? 'want_to_read';
Navigator.pushNamed(
context,
'/book-form',
arguments: {'initialStatus': currentStatus},
);
},
),
ListTile(
leading: const Icon(Icons.note, color: Colors.blue),
title: const Text('添加笔记'),
subtitle: const Text('记录你的想法和笔记'),
onTap: () {
Navigator.pop(context);
// 直接打开添加笔记表单
Navigator.pushNamed(context, '/note-form');
},
),
],
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 顶部指示条
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFE0E0E0),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 20),
// 标题
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Text(
'新增记录',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
),
const SizedBox(height: 16),
// 选项列表
_buildAddOption(
icon: Icons.movie_outlined,
title: '添加观影',
subtitle: '记录你看过的电影',
onTap: () {
Navigator.pop(context);
final statusMap = {
0: 'watched',
1: 'watching',
2: 'want_to_watch',
};
final currentStatus = statusMap[provider.movieStatusIndex] ?? 'want_to_watch';
Navigator.pushNamed(
context,
'/movie-form',
arguments: {'initialStatus': currentStatus},
);
},
),
_buildAddOption(
icon: Icons.menu_book_outlined,
title: '添加阅读',
subtitle: '记录你读过的书',
onTap: () {
Navigator.pop(context);
final statusMap = {
0: 'read',
1: 'reading',
2: 'want_to_read',
};
final currentStatus = statusMap[provider.bookStatusIndex] ?? 'want_to_read';
Navigator.pushNamed(
context,
'/book-form',
arguments: {'initialStatus': currentStatus},
);
},
),
_buildAddOption(
icon: Icons.note_outlined,
title: '添加笔记',
subtitle: '记录你的想法和笔记',
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/note-form');
},
),
],
),
),
);
},
);
}
/// 构建新增选项
Widget _buildAddOption({
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
size: 22,
color: const Color(0xFF666666),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
],
),
),
const Icon(
Icons.chevron_right,
color: Color(0xFFCCCCCC),
size: 20,
),
],
),
),
);
}
}

View File

@@ -32,15 +32,19 @@ class _NoteListItemContent extends StatelessWidget {
return InkWell(
onTap: () {
Navigator.pushNamed(context, '/note-detail', arguments: note);
Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) async {
// 返回时刷新笔记列表
await context.read<AppProvider>().loadNotes();
});
},
onLongPress: () => _showDeleteDialog(context),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE5E5E5)),
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -50,26 +54,27 @@ class _NoteListItemContent extends StatelessWidget {
children: [
// 格式标记
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Text(
isPlainText ? 'TXT' : 'MD',
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
fontSize: 10,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
),
),
),
const SizedBox(width: 8),
const SizedBox(width: 10),
// 时间 - 使用缓存的格式化结果
Text(
_formatDateCached(note.updatedAt),
style: const TextStyle(
fontSize: 11,
fontSize: 12,
color: Color(0xFF999999),
),
),
@@ -78,14 +83,14 @@ class _NoteListItemContent extends StatelessWidget {
if (note.images.isNotEmpty) ...[
const Icon(
Icons.image_outlined,
size: 11,
size: 14,
color: Color(0xFF999999),
),
const SizedBox(width: 2),
const SizedBox(width: 4),
Text(
'${note.images.length}',
style: const TextStyle(
fontSize: 11,
fontSize: 12,
color: Color(0xFF999999),
),
),
@@ -93,33 +98,35 @@ class _NoteListItemContent extends StatelessWidget {
],
),
const SizedBox(height: 8),
const SizedBox(height: 12),
// 内容摘要(去除首尾空格)
Text(
note.summary.trim(),
style: TextStyle(
fontSize: 14,
fontSize: 15,
color: const Color(0xFF1A1A1A),
height: isPlainText ? 1.6 : 1.5,
height: isPlainText ? 1.7 : 1.6,
),
maxLines: isPlainText ? 4 : 3,
overflow: TextOverflow.ellipsis,
),
// 图片预览区域(显示前2张图片)
// 图片预览区域(显示前4张图片)
if (note.images.isNotEmpty) ...[
const SizedBox(height: 10),
const SizedBox(height: 12),
SizedBox(
height: 60,
height: 70,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: note.images.length > 2 ? 2 : note.images.length,
physics: const NeverScrollableScrollPhysics(), // 禁用滚动,提高性能
itemCount: note.images.length > 4 ? 4 : note.images.length,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return _NoteImage(
imagePath: note.images[index],
index: index,
totalCount: note.images.length,
showMore: index == 3 && note.images.length > 4,
);
},
),
@@ -128,21 +135,22 @@ class _NoteListItemContent extends StatelessWidget {
// 底部标签
if (note.tags.isNotEmpty) ...[
const SizedBox(height: 10),
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
spacing: 8,
runSpacing: 8,
children: note.tags.take(3).map((tag) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Text(
tag,
style: const TextStyle(
fontSize: 11,
fontSize: 12,
color: Color(0xFF666666),
),
),
@@ -163,23 +171,50 @@ class _NoteListItemContent extends StatelessWidget {
builder: (context) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
title: const Text('确认删除'),
content: const Text('确定要删除这条笔记吗?'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Text(
'确认删除',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: const Text(
'确定要删除这条笔记吗?删除后可在回收站恢复。',
style: TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消', style: TextStyle(color: Color(0xFF666666))),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF666666),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('取消'),
),
TextButton(
ElevatedButton(
onPressed: () async {
await context.read<AppProvider>().removeNote(note.id);
Navigator.pop(context);
ToastUtil.show(context, '已删除');
},
child: const Text('删除', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
@@ -189,33 +224,52 @@ class _NoteListItemContent extends StatelessWidget {
class _NoteImage extends StatelessWidget {
final String imagePath;
final int index;
final int totalCount;
final bool showMore;
const _NoteImage({
required this.imagePath,
required this.index,
this.totalCount = 0,
this.showMore = false,
});
@override
Widget build(BuildContext context) {
return Container(
width: 60,
height: 60,
margin: EdgeInsets.only(right: index < 1 ? 8 : 0),
width: 70,
height: 70,
margin: EdgeInsets.only(right: index < 3 ? 10 : 0),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E5E5)),
),
child: Image.file(
File(imagePath),
fit: BoxFit.cover,
// 使用低内存缓存
cacheWidth: 120,
cacheHeight: 120,
errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image,
size: 24,
color: Color(0xFFCCCCCC),
),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: showMore
? Container(
color: const Color(0xFFF5F5F5),
child: Center(
child: Text(
'+${totalCount - 4}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF666666),
),
),
),
)
: Image.file(
File(imagePath),
fit: BoxFit.cover,
cacheWidth: 140,
cacheHeight: 140,
errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image,
size: 28,
color: Color(0xFFCCCCCC),
),
),
);
}
}