diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6bb5339
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+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.
+
+
+Copyright (C)
+
+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 .
+
+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
+.
diff --git a/README.md b/README.md
index 831e6af..fe669de 100644
--- a/README.md
+++ b/README.md
@@ -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. 连接设备或启动模拟器
diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
index 31c0271..7fef998 100644
Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
index 619485d..8ccdff8 100644
Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
index 3072b20..4607897 100644
Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
index 7333bee..50d0ffc 100644
Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
index 2784e1e..5b73234 100644
Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 505306e..7c4a4e8 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 15202b1..bbfcfa2 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index ee68795..9de0224 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index ed69095..d18b10f 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index e7638d3..390a843 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png
index a93c14e..797e9d9 100644
Binary files a/assets/icon/app_icon.png and b/assets/icon/app_icon.png differ
diff --git a/lib/main.dart b/lib/main.dart
index e6d25d8..e611379 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -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();
diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart
index 6015faa..67e81fa 100644
--- a/lib/models/data_models.dart
+++ b/lib/models/data_models.dart
@@ -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,
diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart
index eb74b9b..00badaa 100644
--- a/lib/pages/book/book_detail_page.dart
+++ b/lib/pages/book/book_detail_page.dart
@@ -60,13 +60,21 @@ class _BookDetailPageState extends State {
// 作者信息
_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 {
],
),
- // 底部操作栏
- 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 {
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 {
/// 构建状态标签
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 {
/// 构建作者区域
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 {
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 {
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().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(
- future: context.read().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().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 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(
- future: context.read().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(
+ 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 {
);
}
- /// 构建底部操作栏
- 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 {
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().removeBook(widget.book.id);
if (!mounted) return;
@@ -660,9 +744,19 @@ class _BookDetailPageState extends State {
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 {
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 {
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),
),
);
}
diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart
index 118e98b..82836a8 100644
--- a/lib/pages/book/book_form_page.dart
+++ b/lib/pages/book/book_form_page.dart
@@ -29,6 +29,7 @@ class _BookFormPageState extends State {
late TextEditingController _publisherController;
late TextEditingController _summaryController;
late TextEditingController _ratingController;
+ late TextEditingController _isbnController;
// 多值字段的临时输入控制器
final Map _tagControllers = {};
@@ -39,6 +40,7 @@ class _BookFormPageState extends State {
List _genres = [];
String? _coverPath;
String _status = 'want_to_read';
+ DateTime? _publishDate;
@override
void initState() {
@@ -63,6 +65,7 @@ class _BookFormPageState extends State {
_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 {
_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 {
_publisherController.dispose();
_summaryController.dispose();
_ratingController.dispose();
+ _isbnController.dispose();
_tagControllers.values.forEach((c) => c.dispose());
super.dispose();
}
@@ -90,24 +95,51 @@ class _BookFormPageState extends State {
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 {
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 {
),
),
- _buildDivider(),
+ const SizedBox(height: 16),
// 别名
- _buildMultiValueItem(
+ _buildHorizontalMultiValueItem(
label: '别名',
values: _alternateTitles,
hint: '输入别名',
@@ -165,10 +198,10 @@ class _BookFormPageState extends State {
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 {
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 {
),
),
- _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 {
);
}
- /// 构建表单条目(标签 + 内容)
+ /// 构建表单条目(标签 + 内容)- 书籍简介使用
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 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 {
);
}
- /// 构建分隔线
- 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 {
);
}
+ /// 构建纵向日期条目(标签在上,日期在下)
+ 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 _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 {
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 {
summary: _summaryController.text.trim(),
rating: rating,
status: _status,
+ isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null,
+ publishDate: _publishDate,
updatedAt: now,
);
diff --git a/lib/pages/book/book_review_detail_page.dart b/lib/pages/book/book_review_detail_page.dart
new file mode 100644
index 0000000..ce473dc
--- /dev/null
+++ b/lib/pages/book/book_review_detail_page.dart
@@ -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 createState() => _BookReviewDetailPageState();
+}
+
+class _BookReviewDetailPageState extends State {
+ late BookReview _review;
+
+ @override
+ void initState() {
+ super.initState();
+ _review = widget.review;
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _refreshReviewData();
+ }
+
+ Future _refreshReviewData() async {
+ final provider = context.read();
+ 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());
+ }
+}
diff --git a/lib/pages/book/book_reviews_page.dart b/lib/pages/book/book_reviews_page.dart
index 1f4d5e5..fb377bf 100644
--- a/lib/pages/book/book_reviews_page.dart
+++ b/lib/pages/book/book_reviews_page.dart
@@ -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 {
List _reviews = [];
+ List _filteredReviews = [];
bool _isLoading = true;
+ bool _isSearching = false;
+ final TextEditingController _searchController = TextEditingController();
@override
void initState() {
@@ -25,18 +30,45 @@ class _BookReviewsPageState extends State {
_loadReviews();
}
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
Future _loadReviews() async {
setState(() => _isLoading = true);
- try {
- final reviews = await context.read().getBookReviews(widget.book.id);
- setState(() {
- _reviews = reviews;
- _isLoading = false;
- });
- } catch (e) {
- setState(() => _isLoading = false);
- ToastUtil.show(context, '加载失败: $e');
- }
+ final reviews = await context.read().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 {
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 {
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
- : _reviews.isEmpty
+ : _filteredReviews.isEmpty
? _buildEmptyState()
: _buildReviewList(),
);
@@ -95,82 +145,63 @@ class _BookReviewsPageState extends State {
}
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 {
// 底部信息
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 {
).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,
diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart
index 30291a1..1fa8a1b 100644
--- a/lib/pages/movies/movie_detail_page.dart
+++ b/lib/pages/movies/movie_detail_page.dart
@@ -94,11 +94,41 @@ class _MovieDetailPageState extends State {
],
),
- // 底部操作栏
- 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 {
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 {
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 {
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 {
// 上映日期
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 {
/// 构建状态标签
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 {
/// 构建导演区域
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 {
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 {
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().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(
- future: context.read().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().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 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(
- future: context.read().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(
+ 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 {
);
}
- /// 构建底部操作栏
- 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 {
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().removeMovie(widget.movie.id);
if (!mounted) return;
@@ -769,9 +785,19 @@ class _MovieDetailPageState extends State {
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),
),
);
}
diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart
index ebff702..172e2da 100644
--- a/lib/pages/movies/movie_form_page.dart
+++ b/lib/pages/movies/movie_form_page.dart
@@ -41,6 +41,7 @@ class _MovieFormPageState extends State {
String? _posterPath;
String _status = 'want_to_watch';
DateTime? _releaseDate;
+ DateTime? _watchDate;
@override
void initState() {
@@ -74,6 +75,7 @@ class _MovieFormPageState extends State {
_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 {
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 {
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 {
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 {
},
),
),
-
- _buildDivider(),
-
+
+ const SizedBox(height: 16),
+
// 别名
- _buildMultiValueItem(
+ _buildHorizontalMultiValueItem(
label: '别名',
values: _alternateTitles,
hint: '输入别名',
@@ -167,43 +197,11 @@ class _MovieFormPageState extends State {
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 {
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 {
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 {
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 {
);
}
- /// 构建表单条目(标签 + 内容)
+ /// 构建纵向日期条目(标签在上,日期在下)
+ 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 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 {
);
}
- /// 构建分隔线
- 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 {
}
}
- /// 选择日期
+ /// 选择上映日期
Future _selectReleaseDate() async {
final picked = await showDatePicker(
context: context,
@@ -739,6 +897,30 @@ class _MovieFormPageState extends State {
setState(() => _releaseDate = picked);
}
}
+
+ /// 选择观看日期
+ Future _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 _saveMovie() async {
@@ -775,6 +957,7 @@ class _MovieFormPageState extends State {
summary: _summaryController.text.trim(),
rating: rating,
status: _status,
+ watchDate: _watchDate,
createdAt: now,
updatedAt: now,
);
@@ -793,6 +976,7 @@ class _MovieFormPageState extends State {
summary: _summaryController.text.trim(),
rating: rating,
status: _status,
+ watchDate: _watchDate,
updatedAt: now,
);
diff --git a/lib/pages/movies/movie_review_detail_page.dart b/lib/pages/movies/movie_review_detail_page.dart
new file mode 100644
index 0000000..57ae2c2
--- /dev/null
+++ b/lib/pages/movies/movie_review_detail_page.dart
@@ -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 createState() => _MovieReviewDetailPageState();
+}
+
+class _MovieReviewDetailPageState extends State {
+ late MovieReview _review;
+
+ @override
+ void initState() {
+ super.initState();
+ _review = widget.review;
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _refreshReviewData();
+ }
+
+ Future _refreshReviewData() async {
+ final provider = context.read();
+ 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());
+ }
+}
diff --git a/lib/pages/movies/movie_reviews_page.dart b/lib/pages/movies/movie_reviews_page.dart
index 17c3b5d..625fbae 100644
--- a/lib/pages/movies/movie_reviews_page.dart
+++ b/lib/pages/movies/movie_reviews_page.dart
@@ -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 {
List _reviews = [];
+ List _filteredReviews = [];
bool _isLoading = true;
+ bool _isSearching = false;
+ final TextEditingController _searchController = TextEditingController();
@override
void initState() {
@@ -25,22 +30,72 @@ class _MovieReviewsPageState extends State {
_loadReviews();
}
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
Future _loadReviews() async {
setState(() => _isLoading = true);
final reviews = await context.read().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 {
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
- : _reviews.isEmpty
+ : _filteredReviews.isEmpty
? _buildEmptyState()
: _buildReviewList(),
);
@@ -90,115 +145,108 @@ class _MovieReviewsPageState extends State {
}
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 {
).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,
diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart
index 9cfb482..c01175c 100644
--- a/lib/pages/note/note_detail_page.dart
+++ b/lib/pages/note/note_detail_page.dart
@@ -77,28 +77,29 @@ class _NoteDetailPageState extends State {
// 标签区域
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 {
// 图片区域(仅在纯文本模式下显示)
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 {
// 图片标题
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 {
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 {
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 {
(n) => n.id == widget.note.id,
orElse: () => widget.note,
);
- Navigator.pushNamed(context, '/note-form', arguments: currentNote).then((_) {
- context.read().loadNotes();
+ Navigator.pushNamed(context, '/note-form', arguments: currentNote).then((_) async {
+ // 返回时刷新笔记列表
+ await context.read().loadNotes();
});
}
@@ -380,6 +387,35 @@ class _NoteDetailPageState extends State {
);
}
+ /// 构建操作按钮
+ 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 {
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().removeNote(widget.note.id);
+ // 刷新笔记列表
+ await context.read().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),
),
);
}
diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart
index 04330ed..ff133ea 100644
--- a/lib/pages/note/note_form_page.dart
+++ b/lib/pages/note/note_form_page.dart
@@ -53,15 +53,10 @@ class _NoteFormPageState extends State {
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 {
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 {
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 {
_buildFormatSelector(),
],
),
- const SizedBox(height: 8),
+ const SizedBox(height: 12),
// 标签
_buildTagSelector(),
],
@@ -155,18 +158,33 @@ class _NoteFormPageState extends State {
// 图片网格区域
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 {
Text(
'${_images.length}',
style: const TextStyle(
- fontSize: 14,
+ fontSize: 15,
color: Color(0xFF999999),
),
),
@@ -183,24 +201,25 @@ class _NoteFormPageState extends State {
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 {
],
),
- const SizedBox(height: 12),
+ const SizedBox(height: 16),
// 图片网格(4列,正方形铺满)
Expanded(
@@ -220,17 +239,33 @@ class _NoteFormPageState extends State {
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 {
: 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 {
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 {
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 {
/// 构建标签选择器
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 {
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 {
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 {
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 {
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 {
// 已有标签列表
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 {
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 {
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 {
ToastUtil.show(context, _isEditing ? '保存成功' : '添加成功');
+ // 刷新笔记列表
+ await context.read().loadNotes();
+
+ if (!mounted) return;
Navigator.pop(context);
}
@@ -666,8 +846,10 @@ class _NoteFormPageState extends State {
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 {
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),
),
);
}
diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart
index 28a73bb..9916137 100644
--- a/lib/pages/note/note_tab_page.dart
+++ b/lib/pages/note/note_tab_page.dart
@@ -30,6 +30,31 @@ class _NoteTabPageState extends State {
});
}
+ // 用于检测Provider数据变化
+ int _lastNotesCount = 0;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ // 使用watch监听Provider数据变化
+ final provider = context.watch();
+ 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 {
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 {
);
}
+ /// 同步显示列表与Provider数据
+ void _syncDisplayedNotes(List 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 {
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 {
color: Color(0xFFCCCCCC),
),
),
- const SizedBox(height: 24),
+ const SizedBox(height: 20),
const Text(
'暂无笔记',
style: TextStyle(
@@ -162,6 +213,14 @@ class _NoteTabPageState extends State {
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 {
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,
),
),
diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart
index 7beb1d1..a31b986 100644
--- a/lib/pages/profile_page.dart
+++ b/lib/pages/profile_page.dart
@@ -98,30 +98,38 @@ class _ProfilePageState extends State {
// 顶部用户信息
_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 {
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 {
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 {
fontSize: 14,
color: Color(0xFF666666),
),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
),
),
],
@@ -253,56 +257,49 @@ class _ProfilePageState extends State {
: 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 {
);
}
- /// 主统计项
- 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 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 {
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,
+ ),
+ ],
+ ),
+ ),
);
}
diff --git a/lib/pages/recycle_bin_page.dart b/lib/pages/recycle_bin_page.dart
index 780d1de..ea9ac60 100644
--- a/lib/pages/recycle_bin_page.dart
+++ b/lib/pages/recycle_bin_page.dart
@@ -57,6 +57,15 @@ class _RecycleBinPageState extends State 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 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 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 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 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 with SingleTickerProvid
);
}
- /// 构建已删除项卡片
+ /// 构建已删除项卡片 - 紧凑列表布局
Widget _buildDeletedItemCard({
required String title,
required String subtitle,
@@ -156,61 +166,114 @@ class _RecycleBinPageState extends State 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 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 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 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().clearRecycleBin();
@@ -354,9 +483,19 @@ class _RecycleBinPageState extends State 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),
),
);
}
diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart
index d99ce28..983cd1b 100644
--- a/lib/pages/search_page.dart
+++ b/lib/pages/search_page.dart
@@ -105,7 +105,6 @@ class _SearchPageState extends State {
Widget _buildTagFilter() {
return Consumer(
builder: (context, provider, child) {
- // 获取所有笔记的标签
final allTags = {};
for (final note in provider.notes.where((n) => !n.isDeleted)) {
allTags.addAll(note.tags);
@@ -118,18 +117,37 @@ class _SearchPageState extends State {
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 {
});
},
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 {
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 {
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 {
// 搜索输入框
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 {
},
)
: 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 {
}
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 {
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 {
},
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 {
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 {
)
: 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 {
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 {
},
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 {
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 {
)
: 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 {
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 {
}
Widget _buildNoteItem(Note note) {
+ final isPlainText = note.contentType == 'plain_text';
+
return GestureDetector(
onTap: () {
Navigator.push(
@@ -474,30 +587,98 @@ class _SearchPageState extends State {
},
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 {
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,
+ ),
),
);
}
diff --git a/lib/pages/statistics_page.dart b/lib/pages/statistics_page.dart
index 68ba9de..b43e23c 100644
--- a/lib/pages/statistics_page.dart
+++ b/lib/pages/statistics_page.dart
@@ -12,6 +12,7 @@ class StatisticsPage extends StatefulWidget {
}
class _StatisticsPageState extends State {
+ int _selectedTab = 0;
@override
Widget build(BuildContext context) {
@@ -26,21 +27,74 @@ class _StatisticsPageState extends State {
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 movies, List books, List 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 {
// 总数据卡片
_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 {
);
}
- /// 构建统计卡片
- 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 movies, List books, List 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 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 {
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 {
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 {
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),
+ ),
+ ),
+ ],
);
}
}
diff --git a/lib/pages/sync/backup_page.dart b/lib/pages/sync/backup_page.dart
index d6ad370..f0eb98c 100644
--- a/lib/pages/sync/backup_page.dart
+++ b/lib/pages/sync/backup_page.dart
@@ -50,13 +50,12 @@ class _BackupPageState extends State {
: 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 {
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 {
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 {
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 {
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 {
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,
+ ),
+ ),
+ ],
),
),
],
diff --git a/lib/pages/sync/cloud_sync_page.dart b/lib/pages/sync/cloud_sync_page.dart
index 485092d..a776acd 100644
--- a/lib/pages/sync/cloud_sync_page.dart
+++ b/lib/pages/sync/cloud_sync_page.dart
@@ -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),
),
],
),
diff --git a/lib/pages/sync/webdav_sync_page.dart b/lib/pages/sync/webdav_sync_page.dart
index 0489a56..386f712 100644
--- a/lib/pages/sync/webdav_sync_page.dart
+++ b/lib/pages/sync/webdav_sync_page.dart
@@ -228,7 +228,7 @@ class _WebDAVSyncPageState extends State {
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 {
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 {
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 {
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 {
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 {
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 {
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),
),
),
diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart
index 4cd2515..25119de 100644
--- a/lib/utils/database_helper.dart
+++ b/lib/utils/database_helper.dart
@@ -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 _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 _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
diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart
index 0d2ca90..a5ec152 100644
--- a/lib/widgets/bottom_nav_bar.dart
+++ b/lib/widgets/bottom_nav_bar.dart
@@ -11,7 +11,7 @@ class CustomBottomNavBar extends StatelessWidget {
return Consumer(
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,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
}
diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart
index 70cdc6c..f4d8fc8 100644
--- a/lib/widgets/note_list_item.dart
+++ b/lib/widgets/note_list_item.dart
@@ -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().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().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),
+ ),
+ ),
);
}
}