commit 758615813f874ddd8f94bc2cf0ce1ab96cd2859c Author: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sun Aug 10 01:20:45 2025 +0530 chore: initial release diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6430202 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: cogwheel0 +# patreon: # Replace with a single Patreon username +# open_collective: # Replace with a single Open Collective username +# ko_fi: # Replace with a single Ko-fi username +# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +# liberapay: # Replace with a single Liberapay username +# issuehunt: # Replace with a single IssueHunt username +# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +# polar: # Replace with a single Polar username +# buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +# thanks_dev: # Replace with a single thanks.dev username +# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..eee83bf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build-android: + name: Build Android + runs-on: ubuntu-latest + + steps: + #1 Checkout Repository + - name: Checkout Repository + uses: actions/checkout@v3 + + #2 Setup Java + - name: Set Up Java + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: '21' + + #3 Setup Flutter + - name: Set Up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.32.5' + channel: 'stable' + + #4 Install Dependencies + - name: Install Dependencies + run: flutter pub get + + - name: Generate Freezed Classes + run: flutter pub run build_runner build --delete-conflicting-outputs + + #5 Setup Keystore + - name: Create Keystore + run: | + echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > app/conduit-keystore.jks + echo "${{ secrets.ANDROID_KEY_PROPERTIES_BASE64 }}" | base64 --decode > key.properties + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEY_PROPERTIES_BASE64: ${{ secrets.ANDROID_KEY_PROPERTIES_BASE64 }} + working-directory: android + + - name: Build APK + run: flutter build apk --release + + - name: Build appBundle + run: flutter build appbundle --release + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: Releases + path: | + build/app/outputs/flutter-apk/app-release.apk + build/app/outputs/bundle/release/app-release.aab + + - name: Create Release + uses: ncipollo/release-action@v1 + with: + artifacts: "build/app/outputs/flutter-apk/app-release.apk,build/app/outputs/bundle/release/app-release.aab" + tag: ${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f0c7b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ +.github/copilot-instructions.md + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# iOS related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral + +# Coverage +coverage/ + +# Temporary files +*.tmp +*.temp +*.bak +*.orig + +# Environment files +.env +.env.local +.env.production + +# Generated files +*.g.dart +*.freezed.dart + +# FVM (Flutter Version Management) +.fvm/ + +# Fastlane +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Firebase +google-services.json +GoogleService-Info.plist + +# Keystore files +*.keystore +*.jks + +# Gradle files +.gradle/ +**/android/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.hprof + +# CMake +**/android/CMakeLists.txt + +# NDK +**/android/.cxx/ +**/android/local.properties + +# Generated files +**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java + +# Exceptions to above rules +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..bbb2321 --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "edada7c56edf4a183c1735310e123c7f923584f1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: android + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: ios + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..f82a7a2 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,51 @@ +# Conduit Privacy Policy + +Effective date: 2025-08-09 + +Conduit is an open‑source mobile client for Open‑WebUI. This app acts as a client to a server you choose and configure. This policy describes how the app itself handles data on your device. Your configured server may collect, process, and store data under its own policies; please review your server's privacy terms separately. + +## Information We Collect +- Device-stored data: minimal settings and preferences (e.g., theme, UI options) saved locally on your device. +- Authentication tokens: stored securely on your device using platform secure storage when you sign in to your chosen server. +- User-provided content: messages, files, and voice input you choose to send are transmitted directly from your device to your configured server. The app does not operate its own backend. +- Diagnostic information: transient error logs in memory for troubleshooting within a session. The app does not include third‑party analytics. + +## How We Use Information +- Operate core features such as chat, file uploads, and voice input. +- Remember your preferences and sign‑in state on this device. +- Improve reliability (e.g., displaying error information to you). + +## Data Storage and Transfer +- Local storage: preferences and credentials are stored on your device. Access tokens are stored using secure storage where available. +- Network transfer: when you interact with the app, your data is sent to the server you configured. The app does not send your data to any developer‑controlled servers. + +## Permissions +Depending on how you use Conduit, the app may request: +- Microphone: to capture voice input when you opt in. +- Photos/Files: to let you pick and upload attachments. +- Network access: to connect to your configured server. + +## Third‑Party Services +The app does not include third‑party analytics or advertising SDKs. Your configured server or extensions you use may rely on third‑party services subject to their own terms. + +## Security +We use platform‑provided secure storage for sensitive credentials where supported. No security can be guaranteed; protect access to your device and server credentials. + +## Data Retention +- On device: preferences and cached media may persist until you clear app data or uninstall. You can revoke sign‑in by logging out. +- On server: retention is determined by the server you use; consult that server’s policy. + +## Your Choices +- You can change servers, log out, or clear app data in your device settings. +- You can choose not to grant optional permissions; some features may not work without them. + +## Children’s Privacy +Conduit is not directed to children under 13 (or the minimum age required in your jurisdiction). Do not use the app if you do not meet the applicable age requirements. + +## Changes to This Policy +We may update this policy to reflect improvements or legal requirements. Material changes will be reflected in the app bundle and version notes. + +## Contact +For questions or requests about this policy, please contact the app maintainer(s) through the project repository. + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b66daf3 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Conduit - Open-WebUI Mobile Client + +Conduit is an open-source, cross-platform mobile application for Open-WebUI, providing a native mobile experience for interacting with your self-hosted AI infrastructure. + +## Features + +### Core Features +- ✅ **Real-time Chat**: Stream responses from AI models in real-time +- ✅ **Model Selection**: Choose from available models on your server +- ✅ **Conversation Management**: Create, search, and manage chat histories +- ✅ **Markdown Rendering**: Full markdown support with syntax highlighting +- ✅ **Theme Support**: Light, Dark, and System themes + +### Advanced Features +- ✅ **Voice Input**: Use speech-to-text for hands-free interaction +- ✅ **File Uploads**: Support for images and documents (RAG) +- ✅ **Multi-modal Support**: Work with vision models +- ✅ **Secure Storage**: Credentials stored securely using platform keychains + +## Requirements + +- Flutter SDK 3.0.0 or higher +- Android 6.0 (API 23) or higher +- iOS 12.0 or higher +- A running Open-WebUI instance + +## Installation + +1. Clone the repository: +```bash +git clone https://github.com/yourusername/conduit.git +cd conduit +``` + +2. Install dependencies: +```bash +flutter pub get +``` + +3. Generate code: +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` + +4. Run the app: +```bash +# For iOS +flutter run -d ios + +# For Android +flutter run -d android +``` + +## Building for Release + +### Android +```bash +flutter build apk --release +# or for App Bundle +flutter build appbundle --release +``` + +### iOS +```bash +flutter build ios --release +``` + +## Configuration + +### Android +The app requires the following permissions: +- Internet access +- Microphone (for voice input) +- Camera (for taking photos) +- Storage (for file selection) + +### iOS +The app will request permissions for: +- Microphone access (voice input) +- Speech recognition +- Camera access +- Photo library access + +## Architecture + +The app follows a clean architecture pattern with: +- **Riverpod** for state management +- **Dio** for HTTP networking +- **WebSocket** for real-time streaming +- **Flutter Secure Storage** for credential management + +### Project Structure +``` +lib/ +├── core/ +│ ├── models/ # Data models +│ ├── services/ # API and storage services +│ ├── providers/ # Global state providers +│ └── utils/ # Utility functions +├── features/ +│ ├── auth/ # Authentication feature +│ ├── chat/ # Chat interface feature +│ ├── server/ # Server connection feature +│ └── settings/ # Settings feature +└── shared/ + ├── theme/ # App theming + ├── widgets/ # Shared widgets + └── utils/ # Shared utilities +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the project +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## License + +This project is licensed under the GPL3 License - see the LICENSE file for details. + +## Acknowledgments + +- Open-WebUI team for creating an amazing self-hosted AI interface +- Flutter team for the excellent mobile framework +- All contributors and users of Conduit + +## Support + +For issues and feature requests, please use the [GitHub Issues](https://github.com/cogwheel0/conduit/issues) page. \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..98bce5f --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,32 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + + # Production-ready rules + avoid_print: true # Avoid print statements in production + # avoid_web_libraries_in_flutter: true # Avoid web-only libraries + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..c746461 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,70 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "app.cogwheel.conduit" + compileSdk = flutter.compileSdkVersion + ndkVersion = "27.0.12077973" + + defaultConfig { + applicationId = "app.cogwheel.conduit" + minSdk = 23 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + } + } + } + + buildTypes { + getByName("release") { + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + getByName("debug") { + // signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..6553110 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Flutter ProGuard rules +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } +-dontwarn io.flutter.embedding.** + +# Keep your app's classes +-keep class app.cogwheel.conduit.** { *; } + +# Keep Gson and JSON serialization +-keepattributes Signature +-keepattributes *Annotation* +-keep class sun.misc.Unsafe { *; } +-keep class com.google.gson.stream.** { *; } + +# Keep WebSocket functionality +-keep class org.java_websocket.** { *; } +-dontwarn org.java_websocket.** \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b0cf869 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/kotlin/app/cogwheel/conduit/MainActivity.kt b/android/app/src/main/kotlin/app/cogwheel/conduit/MainActivity.kt new file mode 100644 index 0000000..96845da --- /dev/null +++ b/android/app/src/main/kotlin/app/cogwheel/conduit/MainActivity.kt @@ -0,0 +1,6 @@ +package app.cogwheel.conduit + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() { +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..c754196 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..a1f6a19 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..e815fd6 Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..578245c Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..f32c57b Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..b7926e8 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..e815fd6 Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..345888d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..540e42b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..5104457 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..558b310 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..ad45de0 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.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 new file mode 100644 index 0000000..80910f8 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..35bcb6f Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..36cd22c Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..54e5739 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.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 new file mode 100644 index 0000000..777873d Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..fdbaff9 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2204658 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..bb2d4b2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.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 new file mode 100644 index 0000000..29f93b2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..b88d0be Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6fd9e81 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..d602bb1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.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 new file mode 100644 index 0000000..6e6be10 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..e813402 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..717f3b5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..66dad69 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..52d1073 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..dbc9ea9 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..a6497f8 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..0d1fa8f --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ab39a10 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/icons/icon.png b/assets/icons/icon.png new file mode 100644 index 0000000..3f1eeb9 Binary files /dev/null and b/assets/icons/icon.png differ diff --git a/assets/openapi.json b/assets/openapi.json new file mode 100644 index 0000000..4eea14e --- /dev/null +++ b/assets/openapi.json @@ -0,0 +1,22618 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Open WebUI", + "version": "0.1.0" + }, + "paths": { + "/ollama/": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Get Status", + "operationId": "get_status_ollama__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "head": { + "tags": [ + "ollama" + ], + "summary": "Get Status", + "operationId": "get_status_ollama__head", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/ollama/verify": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Verify Connection", + "operationId": "verify_connection_ollama_verify_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__routers__ollama__ConnectionVerificationForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/ollama/config": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Get Config", + "operationId": "get_config_ollama_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/ollama/config/update": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Update Config", + "operationId": "update_config_ollama_config_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__routers__ollama__OllamaConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/ollama/api/tags/{url_idx}": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Get Ollama Tags", + "operationId": "get_ollama_tags_ollama_api_tags__url_idx__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/tags": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Get Ollama Tags", + "operationId": "get_ollama_tags_ollama_api_tags_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/ps": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Get Ollama Loaded Models", + "description": "List models that are currently loaded into Ollama memory, and which node they are loaded on.", + "operationId": "get_ollama_loaded_models_ollama_api_ps_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/ollama/api/version/{url_idx}": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Get Ollama Versions", + "operationId": "get_ollama_versions_ollama_api_version__url_idx__get", + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/version": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Get Ollama Versions", + "operationId": "get_ollama_versions_ollama_api_version_get", + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/unload": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Unload Model", + "operationId": "unload_model_ollama_api_unload_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelNameForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/ollama/api/pull/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Pull Model", + "operationId": "pull_model_ollama_api_pull__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelNameForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/pull": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Pull Model", + "operationId": "pull_model_ollama_api_pull_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0, + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelNameForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/push/{url_idx}": { + "delete": { + "tags": [ + "ollama" + ], + "summary": "Push Model", + "operationId": "push_model_ollama_api_push__url_idx__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushModelForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/push": { + "delete": { + "tags": [ + "ollama" + ], + "summary": "Push Model", + "operationId": "push_model_ollama_api_push_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushModelForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/create/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Create Model", + "operationId": "create_model_ollama_api_create__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateModelForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/create": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Create Model", + "operationId": "create_model_ollama_api_create_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0, + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateModelForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/copy/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Copy Model", + "operationId": "copy_model_ollama_api_copy__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CopyModelForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/copy": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Copy Model", + "operationId": "copy_model_ollama_api_copy_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CopyModelForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/delete/{url_idx}": { + "delete": { + "tags": [ + "ollama" + ], + "summary": "Delete Model", + "operationId": "delete_model_ollama_api_delete__url_idx__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelNameForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/delete": { + "delete": { + "tags": [ + "ollama" + ], + "summary": "Delete Model", + "operationId": "delete_model_ollama_api_delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelNameForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/show": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Show Model Info", + "operationId": "show_model_info_ollama_api_show_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelNameForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/ollama/api/embed/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Embed", + "operationId": "embed_ollama_api_embed__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateEmbedForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/embed": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Embed", + "operationId": "embed_ollama_api_embed_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateEmbedForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/embeddings/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Embeddings", + "operationId": "embeddings_ollama_api_embeddings__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateEmbeddingsForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/embeddings": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Embeddings", + "operationId": "embeddings_ollama_api_embeddings_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateEmbeddingsForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/generate/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Generate Completion", + "operationId": "generate_completion_ollama_api_generate__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateCompletionForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/generate": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Generate Completion", + "operationId": "generate_completion_ollama_api_generate_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateCompletionForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/chat/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Generate Chat Completion", + "operationId": "generate_chat_completion_ollama_api_chat__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + }, + { + "name": "bypass_filter", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Bypass Filter" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/api/chat": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Generate Chat Completion", + "operationId": "generate_chat_completion_ollama_api_chat_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + }, + { + "name": "bypass_filter", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Bypass Filter" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/v1/completions/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Generate Openai Completion", + "operationId": "generate_openai_completion_ollama_v1_completions__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/v1/completions": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Generate Openai Completion", + "operationId": "generate_openai_completion_ollama_v1_completions_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/v1/chat/completions/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Generate Openai Chat Completion", + "operationId": "generate_openai_chat_completion_ollama_v1_chat_completions__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/v1/chat/completions": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Generate Openai Chat Completion", + "operationId": "generate_openai_chat_completion_ollama_v1_chat_completions_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/v1/models/{url_idx}": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Get Openai Models", + "operationId": "get_openai_models_ollama_v1_models__url_idx__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/v1/models": { + "get": { + "tags": [ + "ollama" + ], + "summary": "Get Openai Models", + "operationId": "get_openai_models_ollama_v1_models_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/models/download/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Download Model", + "operationId": "download_model_ollama_models_download__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UrlForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/models/download": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Download Model", + "operationId": "download_model_ollama_models_download_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UrlForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/models/upload/{url_idx}": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Upload Model", + "operationId": "upload_model_ollama_models_upload__url_idx__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_model_ollama_models_upload__url_idx__post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ollama/models/upload": { + "post": { + "tags": [ + "ollama" + ], + "summary": "Upload Model", + "operationId": "upload_model_ollama_models_upload_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_model_ollama_models_upload_post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/openai/config": { + "get": { + "tags": [ + "openai" + ], + "summary": "Get Config", + "operationId": "get_config_openai_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/openai/config/update": { + "post": { + "tags": [ + "openai" + ], + "summary": "Update Config", + "operationId": "update_config_openai_config_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__routers__openai__OpenAIConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/openai/audio/speech": { + "post": { + "tags": [ + "openai" + ], + "summary": "Speech", + "operationId": "speech_openai_audio_speech_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/openai/models/{url_idx}": { + "get": { + "tags": [ + "openai" + ], + "summary": "Get Models", + "operationId": "get_models_openai_models__url_idx__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/openai/models": { + "get": { + "tags": [ + "openai" + ], + "summary": "Get Models", + "operationId": "get_models_openai_models_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "url_idx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Url Idx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/openai/verify": { + "post": { + "tags": [ + "openai" + ], + "summary": "Verify Connection", + "operationId": "verify_connection_openai_verify_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__routers__openai__ConnectionVerificationForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/openai/chat/completions": { + "post": { + "tags": [ + "openai" + ], + "summary": "Generate Chat Completion", + "operationId": "generate_chat_completion_openai_chat_completions_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "bypass_filter", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "title": "Bypass Filter" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/openai/{path}": { + "post": { + "tags": [ + "openai" + ], + "summary": "Proxy", + "description": "Deprecated: proxy all requests to OpenAI API", + "operationId": "proxy_openai__path__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "openai" + ], + "summary": "Proxy", + "description": "Deprecated: proxy all requests to OpenAI API", + "operationId": "proxy_openai__path__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "openai" + ], + "summary": "Proxy", + "description": "Deprecated: proxy all requests to OpenAI API", + "operationId": "proxy_openai__path__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "openai" + ], + "summary": "Proxy", + "description": "Deprecated: proxy all requests to OpenAI API", + "operationId": "proxy_openai__path__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/pipelines/list": { + "get": { + "tags": [ + "pipelines" + ], + "summary": "Get Pipelines List", + "operationId": "get_pipelines_list_api_v1_pipelines_list_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/pipelines/upload": { + "post": { + "tags": [ + "pipelines" + ], + "summary": "Upload Pipeline", + "operationId": "upload_pipeline_api_v1_pipelines_upload_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_pipeline_api_v1_pipelines_upload_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/pipelines/add": { + "post": { + "tags": [ + "pipelines" + ], + "summary": "Add Pipeline", + "operationId": "add_pipeline_api_v1_pipelines_add_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddPipelineForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/pipelines/delete": { + "delete": { + "tags": [ + "pipelines" + ], + "summary": "Delete Pipeline", + "operationId": "delete_pipeline_api_v1_pipelines_delete_delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletePipelineForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/pipelines/": { + "get": { + "tags": [ + "pipelines" + ], + "summary": "Get Pipelines", + "operationId": "get_pipelines_api_v1_pipelines__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "urlIdx", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Urlidx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/pipelines/{pipeline_id}/valves": { + "get": { + "tags": [ + "pipelines" + ], + "summary": "Get Pipeline Valves", + "operationId": "get_pipeline_valves_api_v1_pipelines__pipeline_id__valves_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "pipeline_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Pipeline Id" + } + }, + { + "name": "urlIdx", + "in": "query", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Urlidx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/pipelines/{pipeline_id}/valves/spec": { + "get": { + "tags": [ + "pipelines" + ], + "summary": "Get Pipeline Valves Spec", + "operationId": "get_pipeline_valves_spec_api_v1_pipelines__pipeline_id__valves_spec_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "pipeline_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Pipeline Id" + } + }, + { + "name": "urlIdx", + "in": "query", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Urlidx" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/pipelines/{pipeline_id}/valves/update": { + "post": { + "tags": [ + "pipelines" + ], + "summary": "Update Pipeline Valves", + "operationId": "update_pipeline_valves_api_v1_pipelines__pipeline_id__valves_update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "pipeline_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Pipeline Id" + } + }, + { + "name": "urlIdx", + "in": "query", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Urlidx" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tasks/config": { + "get": { + "tags": [ + "tasks" + ], + "summary": "Get Task Config", + "operationId": "get_task_config_api_v1_tasks_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tasks/config/update": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Update Task Config", + "operationId": "update_task_config_api_v1_tasks_config_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tasks/title/completions": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Generate Title", + "operationId": "generate_title_api_v1_tasks_title_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tasks/follow_up/completions": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Generate Follow Ups", + "operationId": "generate_follow_ups_api_v1_tasks_follow_up_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tasks/tags/completions": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Generate Chat Tags", + "operationId": "generate_chat_tags_api_v1_tasks_tags_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tasks/image_prompt/completions": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Generate Image Prompt", + "operationId": "generate_image_prompt_api_v1_tasks_image_prompt_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tasks/queries/completions": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Generate Queries", + "operationId": "generate_queries_api_v1_tasks_queries_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tasks/auto/completions": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Generate Autocompletion", + "operationId": "generate_autocompletion_api_v1_tasks_auto_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tasks/emoji/completions": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Generate Emoji", + "operationId": "generate_emoji_api_v1_tasks_emoji_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tasks/moa/completions": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Generate Moa Response", + "operationId": "generate_moa_response_api_v1_tasks_moa_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/images/config": { + "get": { + "tags": [ + "images" + ], + "summary": "Get Config", + "operationId": "get_config_api_v1_images_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/images/config/update": { + "post": { + "tags": [ + "images" + ], + "summary": "Update Config", + "operationId": "update_config_api_v1_images_config_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__routers__images__ConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/images/config/url/verify": { + "get": { + "tags": [ + "images" + ], + "summary": "Verify Url", + "operationId": "verify_url_api_v1_images_config_url_verify_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/images/image/config": { + "get": { + "tags": [ + "images" + ], + "summary": "Get Image Config", + "operationId": "get_image_config_api_v1_images_image_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/images/image/config/update": { + "post": { + "tags": [ + "images" + ], + "summary": "Update Image Config", + "operationId": "update_image_config_api_v1_images_image_config_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/images/models": { + "get": { + "tags": [ + "images" + ], + "summary": "Get Models", + "operationId": "get_models_api_v1_images_models_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/images/generations": { + "post": { + "tags": [ + "images" + ], + "summary": "Image Generations", + "operationId": "image_generations_api_v1_images_generations_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateImageForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/audio/config": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get Audio Config", + "operationId": "get_audio_config_api_v1_audio_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/audio/config/update": { + "post": { + "tags": [ + "audio" + ], + "summary": "Update Audio Config", + "operationId": "update_audio_config_api_v1_audio_config_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AudioConfigUpdateForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/audio/speech": { + "post": { + "tags": [ + "audio" + ], + "summary": "Speech", + "operationId": "speech_api_v1_audio_speech_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/audio/transcriptions": { + "post": { + "tags": [ + "audio" + ], + "summary": "Transcription", + "operationId": "transcription_api_v1_audio_transcriptions_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_transcription_api_v1_audio_transcriptions_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/audio/models": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get Models", + "operationId": "get_models_api_v1_audio_models_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/audio/voices": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get Voices", + "operationId": "get_voices_api_v1_audio_voices_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/": { + "get": { + "tags": [ + "retrieval" + ], + "summary": "Get Status", + "operationId": "get_status_api_v1_retrieval__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/retrieval/embedding": { + "get": { + "tags": [ + "retrieval" + ], + "summary": "Get Embedding Config", + "operationId": "get_embedding_config_api_v1_retrieval_embedding_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/embedding/update": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Update Embedding Config", + "operationId": "update_embedding_config_api_v1_retrieval_embedding_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddingModelUpdateForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/config": { + "get": { + "tags": [ + "retrieval" + ], + "summary": "Get Rag Config", + "operationId": "get_rag_config_api_v1_retrieval_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/config/update": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Update Rag Config", + "operationId": "update_rag_config_api_v1_retrieval_config_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__routers__retrieval__ConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/process/file": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Process File", + "operationId": "process_file_api_v1_retrieval_process_file_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProcessFileForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/process/text": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Process Text", + "operationId": "process_text_api_v1_retrieval_process_text_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProcessTextForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/process/youtube": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Process Youtube Video", + "operationId": "process_youtube_video_api_v1_retrieval_process_youtube_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProcessUrlForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/process/web": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Process Web", + "operationId": "process_web_api_v1_retrieval_process_web_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProcessUrlForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/process/web/search": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Process Web Search", + "operationId": "process_web_search_api_v1_retrieval_process_web_search_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/query/doc": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Query Doc Handler", + "operationId": "query_doc_handler_api_v1_retrieval_query_doc_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryDocForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/query/collection": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Query Collection Handler", + "operationId": "query_collection_handler_api_v1_retrieval_query_collection_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryCollectionsForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/delete": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Delete Entries From Collection", + "operationId": "delete_entries_from_collection_api_v1_retrieval_delete_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/reset/db": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Reset Vector Db", + "operationId": "reset_vector_db_api_v1_retrieval_reset_db_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/reset/uploads": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Reset Upload Dir", + "operationId": "reset_upload_dir_api_v1_retrieval_reset_uploads_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Reset Upload Dir Api V1 Retrieval Reset Uploads Post" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/retrieval/ef/{text}": { + "get": { + "tags": [ + "retrieval" + ], + "summary": "Get Embeddings", + "operationId": "get_embeddings_api_v1_retrieval_ef__text__get", + "parameters": [ + { + "name": "text", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Text" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/retrieval/process/files/batch": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Process Files Batch", + "description": "Process a batch of files and save them to the vector database.", + "operationId": "process_files_batch_api_v1_retrieval_process_files_batch_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchProcessFilesForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchProcessFilesResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/configs/import": { + "post": { + "tags": [ + "configs" + ], + "summary": "Import Config", + "operationId": "import_config_api_v1_configs_import_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Import Config Api V1 Configs Import Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/configs/export": { + "get": { + "tags": [ + "configs" + ], + "summary": "Export Config", + "operationId": "export_config_api_v1_configs_export_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Export Config Api V1 Configs Export Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/configs/connections": { + "get": { + "tags": [ + "configs" + ], + "summary": "Get Connections Config", + "operationId": "get_connections_config_api_v1_configs_connections_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionsConfigForm" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "configs" + ], + "summary": "Set Connections Config", + "operationId": "set_connections_config_api_v1_configs_connections_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionsConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionsConfigForm" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/configs/tool_servers": { + "get": { + "tags": [ + "configs" + ], + "summary": "Get Tool Servers Config", + "operationId": "get_tool_servers_config_api_v1_configs_tool_servers_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolServersConfigForm" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "configs" + ], + "summary": "Set Tool Servers Config", + "operationId": "set_tool_servers_config_api_v1_configs_tool_servers_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolServersConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolServersConfigForm" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/configs/tool_servers/verify": { + "post": { + "tags": [ + "configs" + ], + "summary": "Verify Tool Servers Config", + "description": "Verify the connection to the tool server.", + "operationId": "verify_tool_servers_config_api_v1_configs_tool_servers_verify_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolServerConnection" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/configs/code_execution": { + "get": { + "tags": [ + "configs" + ], + "summary": "Get Code Execution Config", + "operationId": "get_code_execution_config_api_v1_configs_code_execution_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodeInterpreterConfigForm" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "configs" + ], + "summary": "Set Code Execution Config", + "operationId": "set_code_execution_config_api_v1_configs_code_execution_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodeInterpreterConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodeInterpreterConfigForm" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/configs/models": { + "get": { + "tags": [ + "configs" + ], + "summary": "Get Models Config", + "operationId": "get_models_config_api_v1_configs_models_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsConfigForm" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "configs" + ], + "summary": "Set Models Config", + "operationId": "set_models_config_api_v1_configs_models_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelsConfigForm" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/configs/suggestions": { + "post": { + "tags": [ + "configs" + ], + "summary": "Set Default Suggestions", + "operationId": "set_default_suggestions_api_v1_configs_suggestions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetDefaultSuggestionsForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PromptSuggestion" + }, + "type": "array", + "title": "Response Set Default Suggestions Api V1 Configs Suggestions Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/configs/banners": { + "get": { + "tags": [ + "configs" + ], + "summary": "Get Banners", + "operationId": "get_banners_api_v1_configs_banners_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BannerModel" + }, + "type": "array", + "title": "Response Get Banners Api V1 Configs Banners Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "configs" + ], + "summary": "Set Banners", + "operationId": "set_banners_api_v1_configs_banners_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetBannersForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BannerModel" + }, + "type": "array", + "title": "Response Set Banners Api V1 Configs Banners Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auths/": { + "get": { + "tags": [ + "auths" + ], + "summary": "Get Session User", + "operationId": "get_session_user_api_v1_auths__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionUserResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auths/update/profile": { + "post": { + "tags": [ + "auths" + ], + "summary": "Update Profile", + "operationId": "update_profile_api_v1_auths_update_profile_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProfileForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__models__auths__UserResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auths/update/password": { + "post": { + "tags": [ + "auths" + ], + "summary": "Update Password", + "operationId": "update_password_api_v1_auths_update_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePasswordForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Update Password Api V1 Auths Update Password Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auths/ldap": { + "post": { + "tags": [ + "auths" + ], + "summary": "Ldap Auth", + "operationId": "ldap_auth_api_v1_auths_ldap_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LdapForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionUserResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auths/signin": { + "post": { + "tags": [ + "auths" + ], + "summary": "Signin", + "operationId": "signin_api_v1_auths_signin_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SigninForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionUserResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auths/signup": { + "post": { + "tags": [ + "auths" + ], + "summary": "Signup", + "operationId": "signup_api_v1_auths_signup_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignupForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionUserResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auths/signout": { + "get": { + "tags": [ + "auths" + ], + "summary": "Signout", + "operationId": "signout_api_v1_auths_signout_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/auths/add": { + "post": { + "tags": [ + "auths" + ], + "summary": "Add User", + "operationId": "add_user_api_v1_auths_add_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddUserForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SigninResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auths/admin/details": { + "get": { + "tags": [ + "auths" + ], + "summary": "Get Admin Details", + "operationId": "get_admin_details_api_v1_auths_admin_details_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auths/admin/config": { + "get": { + "tags": [ + "auths" + ], + "summary": "Get Admin Config", + "operationId": "get_admin_config_api_v1_auths_admin_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "auths" + ], + "summary": "Update Admin Config", + "operationId": "update_admin_config_api_v1_auths_admin_config_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auths/admin/config/ldap/server": { + "get": { + "tags": [ + "auths" + ], + "summary": "Get Ldap Server", + "operationId": "get_ldap_server_api_v1_auths_admin_config_ldap_server_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LdapServerConfig" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "auths" + ], + "summary": "Update Ldap Server", + "operationId": "update_ldap_server_api_v1_auths_admin_config_ldap_server_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LdapServerConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auths/admin/config/ldap": { + "get": { + "tags": [ + "auths" + ], + "summary": "Get Ldap Config", + "operationId": "get_ldap_config_api_v1_auths_admin_config_ldap_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "auths" + ], + "summary": "Update Ldap Config", + "operationId": "update_ldap_config_api_v1_auths_admin_config_ldap_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LdapConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auths/api_key": { + "get": { + "tags": [ + "auths" + ], + "summary": "Get Api Key", + "operationId": "get_api_key_api_v1_auths_api_key_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKey" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "auths" + ], + "summary": "Generate Api Key", + "operationId": "generate_api_key_api_v1_auths_api_key_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKey" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "delete": { + "tags": [ + "auths" + ], + "summary": "Delete Api Key", + "operationId": "delete_api_key_api_v1_auths_api_key_delete", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Api Key Api V1 Auths Api Key Delete" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/active": { + "get": { + "tags": [ + "users" + ], + "summary": "Get Active Users", + "description": "Get a list of active users.", + "operationId": "get_active_users_api_v1_users_active_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/": { + "get": { + "tags": [ + "users" + ], + "summary": "Get Users", + "operationId": "get_users_api_v1_users__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "query", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Query" + } + }, + { + "name": "order_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Order By" + } + }, + { + "name": "direction", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Direction" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 1, + "title": "Page" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/users/all": { + "get": { + "tags": [ + "users" + ], + "summary": "Get All Users", + "operationId": "get_all_users_api_v1_users_all_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserInfoListResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/groups": { + "get": { + "tags": [ + "users" + ], + "summary": "Get User Groups", + "operationId": "get_user_groups_api_v1_users_groups_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/permissions": { + "get": { + "tags": [ + "users" + ], + "summary": "Get User Permissisions", + "operationId": "get_user_permissisions_api_v1_users_permissions_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/default/permissions": { + "get": { + "tags": [ + "users" + ], + "summary": "Get Default User Permissions", + "operationId": "get_default_user_permissions_api_v1_users_default_permissions_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPermissions" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "users" + ], + "summary": "Update Default User Permissions", + "operationId": "update_default_user_permissions_api_v1_users_default_permissions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPermissions" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/user/settings": { + "get": { + "tags": [ + "users" + ], + "summary": "Get User Settings By Session User", + "operationId": "get_user_settings_by_session_user_api_v1_users_user_settings_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserSettings" + }, + { + "type": "null" + } + ], + "title": "Response Get User Settings By Session User Api V1 Users User Settings Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/user/settings/update": { + "post": { + "tags": [ + "users" + ], + "summary": "Update User Settings By Session User", + "operationId": "update_user_settings_by_session_user_api_v1_users_user_settings_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSettings" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSettings" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/user/info": { + "get": { + "tags": [ + "users" + ], + "summary": "Get User Info By Session User", + "operationId": "get_user_info_by_session_user_api_v1_users_user_info_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Response Get User Info By Session User Api V1 Users User Info Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/user/info/update": { + "post": { + "tags": [ + "users" + ], + "summary": "Update User Info By Session User", + "operationId": "update_user_info_by_session_user_api_v1_users_user_info_update_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Response Update User Info By Session User Api V1 Users User Info Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/users/{user_id}": { + "get": { + "tags": [ + "users" + ], + "summary": "Get User By Id", + "operationId": "get_user_by_id_api_v1_users__user_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__routers__users__UserResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "users" + ], + "summary": "Delete User By Id", + "operationId": "delete_user_by_id_api_v1_users__user_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete User By Id Api V1 Users User Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/users/{user_id}/active": { + "get": { + "tags": [ + "users" + ], + "summary": "Get User Active Status By Id", + "operationId": "get_user_active_status_by_id_api_v1_users__user_id__active_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get User Active Status By Id Api V1 Users User Id Active Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/users/{user_id}/update": { + "post": { + "tags": [ + "users" + ], + "summary": "Update User By Id", + "operationId": "update_user_by_id_api_v1_users__user_id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "User Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdateForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserModel" + }, + { + "type": "null" + } + ], + "title": "Response Update User By Id Api V1 Users User Id Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/": { + "get": { + "tags": [ + "channels" + ], + "summary": "Get Channels", + "operationId": "get_channels_api_v1_channels__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ChannelModel" + }, + "type": "array", + "title": "Response Get Channels Api V1 Channels Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/channels/list": { + "get": { + "tags": [ + "channels" + ], + "summary": "Get All Channels", + "operationId": "get_all_channels_api_v1_channels_list_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ChannelModel" + }, + "type": "array", + "title": "Response Get All Channels Api V1 Channels List Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/channels/create": { + "post": { + "tags": [ + "channels" + ], + "summary": "Create New Channel", + "operationId": "create_new_channel_api_v1_channels_create_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChannelModel" + }, + { + "type": "null" + } + ], + "title": "Response Create New Channel Api V1 Channels Create Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/channels/{id}": { + "get": { + "tags": [ + "channels" + ], + "summary": "Get Channel By Id", + "operationId": "get_channel_by_id_api_v1_channels__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChannelModel" + }, + { + "type": "null" + } + ], + "title": "Response Get Channel By Id Api V1 Channels Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/update": { + "post": { + "tags": [ + "channels" + ], + "summary": "Update Channel By Id", + "operationId": "update_channel_by_id_api_v1_channels__id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChannelModel" + }, + { + "type": "null" + } + ], + "title": "Response Update Channel By Id Api V1 Channels Id Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/delete": { + "delete": { + "tags": [ + "channels" + ], + "summary": "Delete Channel By Id", + "operationId": "delete_channel_by_id_api_v1_channels__id__delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Channel By Id Api V1 Channels Id Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/messages": { + "get": { + "tags": [ + "channels" + ], + "summary": "Get Channel Messages", + "operationId": "get_channel_messages_api_v1_channels__id__messages_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "skip", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0, + "title": "Skip" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageUserResponse" + }, + "title": "Response Get Channel Messages Api V1 Channels Id Messages Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/messages/post": { + "post": { + "tags": [ + "channels" + ], + "summary": "Post New Message", + "operationId": "post_new_message_api_v1_channels__id__messages_post_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__models__messages__MessageForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageModel" + }, + { + "type": "null" + } + ], + "title": "Response Post New Message Api V1 Channels Id Messages Post Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/messages/{message_id}": { + "get": { + "tags": [ + "channels" + ], + "summary": "Get Channel Message", + "operationId": "get_channel_message_api_v1_channels__id__messages__message_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Message Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageUserResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Channel Message Api V1 Channels Id Messages Message Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/messages/{message_id}/thread": { + "get": { + "tags": [ + "channels" + ], + "summary": "Get Channel Thread Messages", + "operationId": "get_channel_thread_messages_api_v1_channels__id__messages__message_id__thread_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Message Id" + } + }, + { + "name": "skip", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0, + "title": "Skip" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageUserResponse" + }, + "title": "Response Get Channel Thread Messages Api V1 Channels Id Messages Message Id Thread Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/messages/{message_id}/update": { + "post": { + "tags": [ + "channels" + ], + "summary": "Update Message By Id", + "operationId": "update_message_by_id_api_v1_channels__id__messages__message_id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Message Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__models__messages__MessageForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MessageModel" + }, + { + "type": "null" + } + ], + "title": "Response Update Message By Id Api V1 Channels Id Messages Message Id Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/messages/{message_id}/reactions/add": { + "post": { + "tags": [ + "channels" + ], + "summary": "Add Reaction To Message", + "operationId": "add_reaction_to_message_api_v1_channels__id__messages__message_id__reactions_add_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Message Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReactionForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Add Reaction To Message Api V1 Channels Id Messages Message Id Reactions Add Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/messages/{message_id}/reactions/remove": { + "post": { + "tags": [ + "channels" + ], + "summary": "Remove Reaction By Id And User Id And Name", + "operationId": "remove_reaction_by_id_and_user_id_and_name_api_v1_channels__id__messages__message_id__reactions_remove_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Message Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReactionForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Remove Reaction By Id And User Id And Name Api V1 Channels Id Messages Message Id Reactions Remove Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/channels/{id}/messages/{message_id}/delete": { + "delete": { + "tags": [ + "channels" + ], + "summary": "Delete Message By Id", + "operationId": "delete_message_by_id_api_v1_channels__id__messages__message_id__delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Message Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Message By Id Api V1 Channels Id Messages Message Id Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/list": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get Session User Chat List", + "operationId": "get_session_user_chat_list_api_v1_chats_list_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatTitleIdResponse" + }, + "title": "Response Get Session User Chat List Api V1 Chats List Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get Session User Chat List", + "operationId": "get_session_user_chat_list_api_v1_chats__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatTitleIdResponse" + }, + "title": "Response Get Session User Chat List Api V1 Chats Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "chats" + ], + "summary": "Delete All User Chats", + "operationId": "delete_all_user_chats_api_v1_chats__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete All User Chats Api V1 Chats Delete" + } + } + } + } + } + } + }, + "/api/v1/chats/list/user/{user_id}": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get User Chat List By User Id", + "operationId": "get_user_chat_list_by_user_id_api_v1_chats_list_user__user_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "User Id" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page" + } + }, + { + "name": "query", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Query" + } + }, + { + "name": "order_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Order By" + } + }, + { + "name": "direction", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Direction" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatTitleIdResponse" + }, + "title": "Response Get User Chat List By User Id Api V1 Chats List User User Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/new": { + "post": { + "tags": [ + "chats" + ], + "summary": "Create New Chat", + "operationId": "create_new_chat_api_v1_chats_new_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Create New Chat Api V1 Chats New Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/chats/import": { + "post": { + "tags": [ + "chats" + ], + "summary": "Import Chat", + "operationId": "import_chat_api_v1_chats_import_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatImportForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Import Chat Api V1 Chats Import Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/chats/search": { + "get": { + "tags": [ + "chats" + ], + "summary": "Search User Chats", + "operationId": "search_user_chats_api_v1_chats_search_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "text", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Text" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatTitleIdResponse" + }, + "title": "Response Search User Chats Api V1 Chats Search Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/folder/{folder_id}": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get Chats By Folder Id", + "operationId": "get_chats_by_folder_id_api_v1_chats_folder__folder_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "folder_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Folder Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatResponse" + }, + "title": "Response Get Chats By Folder Id Api V1 Chats Folder Folder Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/pinned": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get User Pinned Chats", + "operationId": "get_user_pinned_chats_api_v1_chats_pinned_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ChatTitleIdResponse" + }, + "type": "array", + "title": "Response Get User Pinned Chats Api V1 Chats Pinned Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/chats/all": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get User Chats", + "operationId": "get_user_chats_api_v1_chats_all_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ChatResponse" + }, + "type": "array", + "title": "Response Get User Chats Api V1 Chats All Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/chats/all/archived": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get User Archived Chats", + "operationId": "get_user_archived_chats_api_v1_chats_all_archived_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ChatResponse" + }, + "type": "array", + "title": "Response Get User Archived Chats Api V1 Chats All Archived Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/chats/all/tags": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get All User Tags", + "operationId": "get_all_user_tags_api_v1_chats_all_tags_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TagModel" + }, + "type": "array", + "title": "Response Get All User Tags Api V1 Chats All Tags Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/chats/all/db": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get All User Chats In Db", + "operationId": "get_all_user_chats_in_db_api_v1_chats_all_db_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ChatResponse" + }, + "type": "array", + "title": "Response Get All User Chats In Db Api V1 Chats All Db Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/chats/archived": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get Archived Session User Chat List", + "operationId": "get_archived_session_user_chat_list_api_v1_chats_archived_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page" + } + }, + { + "name": "query", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Query" + } + }, + { + "name": "order_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Order By" + } + }, + { + "name": "direction", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Direction" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatTitleIdResponse" + }, + "title": "Response Get Archived Session User Chat List Api V1 Chats Archived Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/archive/all": { + "post": { + "tags": [ + "chats" + ], + "summary": "Archive All Chats", + "operationId": "archive_all_chats_api_v1_chats_archive_all_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Archive All Chats Api V1 Chats Archive All Post" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/chats/share/{share_id}": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get Shared Chat By Id", + "operationId": "get_shared_chat_by_id_api_v1_chats_share__share_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "share_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Share Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Shared Chat By Id Api V1 Chats Share Share Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/tags": { + "post": { + "tags": [ + "chats" + ], + "summary": "Get User Chat List By Tag Name", + "operationId": "get_user_chat_list_by_tag_name_api_v1_chats_tags_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagFilterForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ChatTitleIdResponse" + }, + "type": "array", + "title": "Response Get User Chat List By Tag Name Api V1 Chats Tags Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/chats/{id}": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get Chat By Id", + "operationId": "get_chat_by_id_api_v1_chats__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Chat By Id Api V1 Chats Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "chats" + ], + "summary": "Update Chat By Id", + "operationId": "update_chat_by_id_api_v1_chats__id__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Update Chat By Id Api V1 Chats Id Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "chats" + ], + "summary": "Delete Chat By Id", + "operationId": "delete_chat_by_id_api_v1_chats__id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Chat By Id Api V1 Chats Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/messages/{message_id}": { + "post": { + "tags": [ + "chats" + ], + "summary": "Update Chat Message By Id", + "operationId": "update_chat_message_by_id_api_v1_chats__id__messages__message_id__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Message Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/open_webui__routers__chats__MessageForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Update Chat Message By Id Api V1 Chats Id Messages Message Id Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/messages/{message_id}/event": { + "post": { + "tags": [ + "chats" + ], + "summary": "Send Chat Message Event By Id", + "operationId": "send_chat_message_event_by_id_api_v1_chats__id__messages__message_id__event_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Message Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Response Send Chat Message Event By Id Api V1 Chats Id Messages Message Id Event Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/pinned": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get Pinned Status By Id", + "operationId": "get_pinned_status_by_id_api_v1_chats__id__pinned_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Response Get Pinned Status By Id Api V1 Chats Id Pinned Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/pin": { + "post": { + "tags": [ + "chats" + ], + "summary": "Pin Chat By Id", + "operationId": "pin_chat_by_id_api_v1_chats__id__pin_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Pin Chat By Id Api V1 Chats Id Pin Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/clone": { + "post": { + "tags": [ + "chats" + ], + "summary": "Clone Chat By Id", + "operationId": "clone_chat_by_id_api_v1_chats__id__clone_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloneForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Clone Chat By Id Api V1 Chats Id Clone Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/clone/shared": { + "post": { + "tags": [ + "chats" + ], + "summary": "Clone Shared Chat By Id", + "operationId": "clone_shared_chat_by_id_api_v1_chats__id__clone_shared_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Clone Shared Chat By Id Api V1 Chats Id Clone Shared Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/archive": { + "post": { + "tags": [ + "chats" + ], + "summary": "Archive Chat By Id", + "operationId": "archive_chat_by_id_api_v1_chats__id__archive_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Archive Chat By Id Api V1 Chats Id Archive Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/share": { + "post": { + "tags": [ + "chats" + ], + "summary": "Share Chat By Id", + "operationId": "share_chat_by_id_api_v1_chats__id__share_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Share Chat By Id Api V1 Chats Id Share Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "chats" + ], + "summary": "Delete Shared Chat By Id", + "operationId": "delete_shared_chat_by_id_api_v1_chats__id__share_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Response Delete Shared Chat By Id Api V1 Chats Id Share Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/folder": { + "post": { + "tags": [ + "chats" + ], + "summary": "Update Chat Folder Id By Id", + "operationId": "update_chat_folder_id_by_id_api_v1_chats__id__folder_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatFolderIdForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChatResponse" + }, + { + "type": "null" + } + ], + "title": "Response Update Chat Folder Id By Id Api V1 Chats Id Folder Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/tags": { + "get": { + "tags": [ + "chats" + ], + "summary": "Get Chat Tags By Id", + "operationId": "get_chat_tags_by_id_api_v1_chats__id__tags_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagModel" + }, + "title": "Response Get Chat Tags By Id Api V1 Chats Id Tags Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "chats" + ], + "summary": "Add Tag By Id And Tag Name", + "operationId": "add_tag_by_id_and_tag_name_api_v1_chats__id__tags_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagModel" + }, + "title": "Response Add Tag By Id And Tag Name Api V1 Chats Id Tags Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "chats" + ], + "summary": "Delete Tag By Id And Tag Name", + "operationId": "delete_tag_by_id_and_tag_name_api_v1_chats__id__tags_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagModel" + }, + "title": "Response Delete Tag By Id And Tag Name Api V1 Chats Id Tags Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chats/{id}/tags/all": { + "delete": { + "tags": [ + "chats" + ], + "summary": "Delete All Tags By Id", + "operationId": "delete_all_tags_by_id_api_v1_chats__id__tags_all_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Response Delete All Tags By Id Api V1 Chats Id Tags All Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/notes/": { + "get": { + "tags": [ + "notes" + ], + "summary": "Get Notes", + "operationId": "get_notes_api_v1_notes__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/NoteUserResponse" + }, + "type": "array", + "title": "Response Get Notes Api V1 Notes Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/notes/list": { + "get": { + "tags": [ + "notes" + ], + "summary": "Get Note List", + "operationId": "get_note_list_api_v1_notes_list_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/NoteTitleIdResponse" + }, + "type": "array", + "title": "Response Get Note List Api V1 Notes List Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/notes/create": { + "post": { + "tags": [ + "notes" + ], + "summary": "Create New Note", + "operationId": "create_new_note_api_v1_notes_create_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NoteForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/NoteModel" + }, + { + "type": "null" + } + ], + "title": "Response Create New Note Api V1 Notes Create Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/notes/{id}": { + "get": { + "tags": [ + "notes" + ], + "summary": "Get Note By Id", + "operationId": "get_note_by_id_api_v1_notes__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/NoteModel" + }, + { + "type": "null" + } + ], + "title": "Response Get Note By Id Api V1 Notes Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/notes/{id}/update": { + "post": { + "tags": [ + "notes" + ], + "summary": "Update Note By Id", + "operationId": "update_note_by_id_api_v1_notes__id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NoteForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/NoteModel" + }, + { + "type": "null" + } + ], + "title": "Response Update Note By Id Api V1 Notes Id Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/notes/{id}/delete": { + "delete": { + "tags": [ + "notes" + ], + "summary": "Delete Note By Id", + "operationId": "delete_note_by_id_api_v1_notes__id__delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Note By Id Api V1 Notes Id Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/": { + "get": { + "tags": [ + "models" + ], + "summary": "Get Models", + "operationId": "get_models_api_v1_models__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelUserResponse" + }, + "title": "Response Get Models Api V1 Models Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/base": { + "get": { + "tags": [ + "models" + ], + "summary": "Get Base Models", + "operationId": "get_base_models_api_v1_models_base_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ModelResponse" + }, + "type": "array", + "title": "Response Get Base Models Api V1 Models Base Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/models/create": { + "post": { + "tags": [ + "models" + ], + "summary": "Create New Model", + "operationId": "create_new_model_api_v1_models_create_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelModel" + }, + { + "type": "null" + } + ], + "title": "Response Create New Model Api V1 Models Create Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/models/model": { + "get": { + "tags": [ + "models" + ], + "summary": "Get Model By Id", + "operationId": "get_model_by_id_api_v1_models_model_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Model By Id Api V1 Models Model Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/model/toggle": { + "post": { + "tags": [ + "models" + ], + "summary": "Toggle Model By Id", + "operationId": "toggle_model_by_id_api_v1_models_model_toggle_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelResponse" + }, + { + "type": "null" + } + ], + "title": "Response Toggle Model By Id Api V1 Models Model Toggle Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/model/update": { + "post": { + "tags": [ + "models" + ], + "summary": "Update Model By Id", + "operationId": "update_model_by_id_api_v1_models_model_update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelModel" + }, + { + "type": "null" + } + ], + "title": "Response Update Model By Id Api V1 Models Model Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/model/delete": { + "delete": { + "tags": [ + "models" + ], + "summary": "Delete Model By Id", + "operationId": "delete_model_by_id_api_v1_models_model_delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Model By Id Api V1 Models Model Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/delete/all": { + "delete": { + "tags": [ + "models" + ], + "summary": "Delete All Models", + "operationId": "delete_all_models_api_v1_models_delete_all_delete", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete All Models Api V1 Models Delete All Delete" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/knowledge/": { + "get": { + "tags": [ + "knowledge" + ], + "summary": "Get Knowledge", + "operationId": "get_knowledge_api_v1_knowledge__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/KnowledgeUserResponse" + }, + "type": "array", + "title": "Response Get Knowledge Api V1 Knowledge Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/knowledge/list": { + "get": { + "tags": [ + "knowledge" + ], + "summary": "Get Knowledge List", + "operationId": "get_knowledge_list_api_v1_knowledge_list_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/KnowledgeUserResponse" + }, + "type": "array", + "title": "Response Get Knowledge List Api V1 Knowledge List Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/knowledge/create": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Create New Knowledge", + "operationId": "create_new_knowledge_api_v1_knowledge_create_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeResponse" + }, + { + "type": "null" + } + ], + "title": "Response Create New Knowledge Api V1 Knowledge Create Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/knowledge/reindex": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Reindex Knowledge Files", + "operationId": "reindex_knowledge_files_api_v1_knowledge_reindex_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Reindex Knowledge Files Api V1 Knowledge Reindex Post" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/knowledge/{id}": { + "get": { + "tags": [ + "knowledge" + ], + "summary": "Get Knowledge By Id", + "operationId": "get_knowledge_by_id_api_v1_knowledge__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeFilesResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Knowledge By Id Api V1 Knowledge Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/knowledge/{id}/update": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Update Knowledge By Id", + "operationId": "update_knowledge_by_id_api_v1_knowledge__id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeFilesResponse" + }, + { + "type": "null" + } + ], + "title": "Response Update Knowledge By Id Api V1 Knowledge Id Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/knowledge/{id}/file/add": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Add File To Knowledge By Id", + "operationId": "add_file_to_knowledge_by_id_api_v1_knowledge__id__file_add_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeFileIdForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeFilesResponse" + }, + { + "type": "null" + } + ], + "title": "Response Add File To Knowledge By Id Api V1 Knowledge Id File Add Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/knowledge/{id}/file/update": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Update File From Knowledge By Id", + "operationId": "update_file_from_knowledge_by_id_api_v1_knowledge__id__file_update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeFileIdForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeFilesResponse" + }, + { + "type": "null" + } + ], + "title": "Response Update File From Knowledge By Id Api V1 Knowledge Id File Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/knowledge/{id}/file/remove": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Remove File From Knowledge By Id", + "operationId": "remove_file_from_knowledge_by_id_api_v1_knowledge__id__file_remove_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeFileIdForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeFilesResponse" + }, + { + "type": "null" + } + ], + "title": "Response Remove File From Knowledge By Id Api V1 Knowledge Id File Remove Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/knowledge/{id}/delete": { + "delete": { + "tags": [ + "knowledge" + ], + "summary": "Delete Knowledge By Id", + "operationId": "delete_knowledge_by_id_api_v1_knowledge__id__delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Knowledge By Id Api V1 Knowledge Id Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/knowledge/{id}/reset": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Reset Knowledge By Id", + "operationId": "reset_knowledge_by_id_api_v1_knowledge__id__reset_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeResponse" + }, + { + "type": "null" + } + ], + "title": "Response Reset Knowledge By Id Api V1 Knowledge Id Reset Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/knowledge/{id}/files/batch/add": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Add Files To Knowledge Batch", + "description": "Add multiple files to a knowledge base", + "operationId": "add_files_to_knowledge_batch_api_v1_knowledge__id__files_batch_add_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KnowledgeFileIdForm" + }, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeFilesResponse" + }, + { + "type": "null" + } + ], + "title": "Response Add Files To Knowledge Batch Api V1 Knowledge Id Files Batch Add Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/prompts/": { + "get": { + "tags": [ + "prompts" + ], + "summary": "Get Prompts", + "operationId": "get_prompts_api_v1_prompts__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PromptModel" + }, + "type": "array", + "title": "Response Get Prompts Api V1 Prompts Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/prompts/list": { + "get": { + "tags": [ + "prompts" + ], + "summary": "Get Prompt List", + "operationId": "get_prompt_list_api_v1_prompts_list_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PromptUserResponse" + }, + "type": "array", + "title": "Response Get Prompt List Api V1 Prompts List Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/prompts/create": { + "post": { + "tags": [ + "prompts" + ], + "summary": "Create New Prompt", + "operationId": "create_new_prompt_api_v1_prompts_create_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PromptForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptModel" + }, + { + "type": "null" + } + ], + "title": "Response Create New Prompt Api V1 Prompts Create Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/prompts/command/{command}": { + "get": { + "tags": [ + "prompts" + ], + "summary": "Get Prompt By Command", + "operationId": "get_prompt_by_command_api_v1_prompts_command__command__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "command", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Command" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptModel" + }, + { + "type": "null" + } + ], + "title": "Response Get Prompt By Command Api V1 Prompts Command Command Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/prompts/command/{command}/update": { + "post": { + "tags": [ + "prompts" + ], + "summary": "Update Prompt By Command", + "operationId": "update_prompt_by_command_api_v1_prompts_command__command__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "command", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Command" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PromptForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptModel" + }, + { + "type": "null" + } + ], + "title": "Response Update Prompt By Command Api V1 Prompts Command Command Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/prompts/command/{command}/delete": { + "delete": { + "tags": [ + "prompts" + ], + "summary": "Delete Prompt By Command", + "operationId": "delete_prompt_by_command_api_v1_prompts_command__command__delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "command", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Command" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Prompt By Command Api V1 Prompts Command Command Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tools/": { + "get": { + "tags": [ + "tools" + ], + "summary": "Get Tools", + "operationId": "get_tools_api_v1_tools__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ToolUserResponse" + }, + "type": "array", + "title": "Response Get Tools Api V1 Tools Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tools/list": { + "get": { + "tags": [ + "tools" + ], + "summary": "Get Tool List", + "operationId": "get_tool_list_api_v1_tools_list_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ToolUserResponse" + }, + "type": "array", + "title": "Response Get Tool List Api V1 Tools List Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tools/load/url": { + "post": { + "tags": [ + "tools" + ], + "summary": "Load Tool From Url", + "operationId": "load_tool_from_url_api_v1_tools_load_url_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoadUrlForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Response Load Tool From Url Api V1 Tools Load Url Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tools/export": { + "get": { + "tags": [ + "tools" + ], + "summary": "Export Tools", + "operationId": "export_tools_api_v1_tools_export_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ToolModel" + }, + "type": "array", + "title": "Response Export Tools Api V1 Tools Export Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tools/create": { + "post": { + "tags": [ + "tools" + ], + "summary": "Create New Tools", + "operationId": "create_new_tools_api_v1_tools_create_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolResponse" + }, + { + "type": "null" + } + ], + "title": "Response Create New Tools Api V1 Tools Create Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/tools/id/{id}": { + "get": { + "tags": [ + "tools" + ], + "summary": "Get Tools By Id", + "operationId": "get_tools_by_id_api_v1_tools_id__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolModel" + }, + { + "type": "null" + } + ], + "title": "Response Get Tools By Id Api V1 Tools Id Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tools/id/{id}/update": { + "post": { + "tags": [ + "tools" + ], + "summary": "Update Tools By Id", + "operationId": "update_tools_by_id_api_v1_tools_id__id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolModel" + }, + { + "type": "null" + } + ], + "title": "Response Update Tools By Id Api V1 Tools Id Id Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tools/id/{id}/delete": { + "delete": { + "tags": [ + "tools" + ], + "summary": "Delete Tools By Id", + "operationId": "delete_tools_by_id_api_v1_tools_id__id__delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Tools By Id Api V1 Tools Id Id Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tools/id/{id}/valves": { + "get": { + "tags": [ + "tools" + ], + "summary": "Get Tools Valves By Id", + "operationId": "get_tools_valves_by_id_api_v1_tools_id__id__valves_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Get Tools Valves By Id Api V1 Tools Id Id Valves Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tools/id/{id}/valves/spec": { + "get": { + "tags": [ + "tools" + ], + "summary": "Get Tools Valves Spec By Id", + "operationId": "get_tools_valves_spec_by_id_api_v1_tools_id__id__valves_spec_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Get Tools Valves Spec By Id Api V1 Tools Id Id Valves Spec Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tools/id/{id}/valves/update": { + "post": { + "tags": [ + "tools" + ], + "summary": "Update Tools Valves By Id", + "operationId": "update_tools_valves_by_id_api_v1_tools_id__id__valves_update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Update Tools Valves By Id Api V1 Tools Id Id Valves Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tools/id/{id}/valves/user": { + "get": { + "tags": [ + "tools" + ], + "summary": "Get Tools User Valves By Id", + "operationId": "get_tools_user_valves_by_id_api_v1_tools_id__id__valves_user_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Get Tools User Valves By Id Api V1 Tools Id Id Valves User Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tools/id/{id}/valves/user/spec": { + "get": { + "tags": [ + "tools" + ], + "summary": "Get Tools User Valves Spec By Id", + "operationId": "get_tools_user_valves_spec_by_id_api_v1_tools_id__id__valves_user_spec_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Get Tools User Valves Spec By Id Api V1 Tools Id Id Valves User Spec Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tools/id/{id}/valves/user/update": { + "post": { + "tags": [ + "tools" + ], + "summary": "Update Tools User Valves By Id", + "operationId": "update_tools_user_valves_by_id_api_v1_tools_id__id__valves_user_update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Update Tools User Valves By Id Api V1 Tools Id Id Valves User Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/memories/ef": { + "get": { + "tags": [ + "memories" + ], + "summary": "Get Embeddings", + "operationId": "get_embeddings_api_v1_memories_ef_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/memories/": { + "get": { + "tags": [ + "memories" + ], + "summary": "Get Memories", + "operationId": "get_memories_api_v1_memories__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MemoryModel" + }, + "type": "array", + "title": "Response Get Memories Api V1 Memories Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/memories/add": { + "post": { + "tags": [ + "memories" + ], + "summary": "Add Memory", + "operationId": "add_memory_api_v1_memories_add_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddMemoryForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemoryModel" + }, + { + "type": "null" + } + ], + "title": "Response Add Memory Api V1 Memories Add Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/memories/query": { + "post": { + "tags": [ + "memories" + ], + "summary": "Query Memory", + "operationId": "query_memory_api_v1_memories_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryMemoryForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/memories/reset": { + "post": { + "tags": [ + "memories" + ], + "summary": "Reset Memory From Vector Db", + "operationId": "reset_memory_from_vector_db_api_v1_memories_reset_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Reset Memory From Vector Db Api V1 Memories Reset Post" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/memories/delete/user": { + "delete": { + "tags": [ + "memories" + ], + "summary": "Delete Memory By User Id", + "operationId": "delete_memory_by_user_id_api_v1_memories_delete_user_delete", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Memory By User Id Api V1 Memories Delete User Delete" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/memories/{memory_id}/update": { + "post": { + "tags": [ + "memories" + ], + "summary": "Update Memory By Id", + "operationId": "update_memory_by_id_api_v1_memories__memory_id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "memory_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Memory Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryUpdateModel" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemoryModel" + }, + { + "type": "null" + } + ], + "title": "Response Update Memory By Id Api V1 Memories Memory Id Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/memories/{memory_id}": { + "delete": { + "tags": [ + "memories" + ], + "summary": "Delete Memory By Id", + "operationId": "delete_memory_by_id_api_v1_memories__memory_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "memory_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Memory Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Memory By Id Api V1 Memories Memory Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/folders/": { + "get": { + "tags": [ + "folders" + ], + "summary": "Get Folders", + "operationId": "get_folders_api_v1_folders__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FolderModel" + }, + "type": "array", + "title": "Response Get Folders Api V1 Folders Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "folders" + ], + "summary": "Create Folder", + "operationId": "create_folder_api_v1_folders__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FolderForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/folders/{id}": { + "get": { + "tags": [ + "folders" + ], + "summary": "Get Folder By Id", + "operationId": "get_folder_by_id_api_v1_folders__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FolderModel" + }, + { + "type": "null" + } + ], + "title": "Response Get Folder By Id Api V1 Folders Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "folders" + ], + "summary": "Delete Folder By Id", + "operationId": "delete_folder_by_id_api_v1_folders__id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/folders/{id}/update": { + "post": { + "tags": [ + "folders" + ], + "summary": "Update Folder Name By Id", + "operationId": "update_folder_name_by_id_api_v1_folders__id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FolderForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/folders/{id}/update/parent": { + "post": { + "tags": [ + "folders" + ], + "summary": "Update Folder Parent Id By Id", + "operationId": "update_folder_parent_id_by_id_api_v1_folders__id__update_parent_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FolderParentIdForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/folders/{id}/update/expanded": { + "post": { + "tags": [ + "folders" + ], + "summary": "Update Folder Is Expanded By Id", + "operationId": "update_folder_is_expanded_by_id_api_v1_folders__id__update_expanded_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FolderIsExpandedForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/groups/": { + "get": { + "tags": [ + "groups" + ], + "summary": "Get Groups", + "operationId": "get_groups_api_v1_groups__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/GroupResponse" + }, + "type": "array", + "title": "Response Get Groups Api V1 Groups Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/groups/create": { + "post": { + "tags": [ + "groups" + ], + "summary": "Create New Group", + "operationId": "create_new_group_api_v1_groups_create_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/GroupResponse" + }, + { + "type": "null" + } + ], + "title": "Response Create New Group Api V1 Groups Create Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/groups/id/{id}": { + "get": { + "tags": [ + "groups" + ], + "summary": "Get Group By Id", + "operationId": "get_group_by_id_api_v1_groups_id__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/GroupResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Group By Id Api V1 Groups Id Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/groups/id/{id}/update": { + "post": { + "tags": [ + "groups" + ], + "summary": "Update Group By Id", + "operationId": "update_group_by_id_api_v1_groups_id__id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupUpdateForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/GroupResponse" + }, + { + "type": "null" + } + ], + "title": "Response Update Group By Id Api V1 Groups Id Id Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/groups/id/{id}/users/add": { + "post": { + "tags": [ + "groups" + ], + "summary": "Add User To Group", + "operationId": "add_user_to_group_api_v1_groups_id__id__users_add_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserIdsForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/GroupResponse" + }, + { + "type": "null" + } + ], + "title": "Response Add User To Group Api V1 Groups Id Id Users Add Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/groups/id/{id}/users/remove": { + "post": { + "tags": [ + "groups" + ], + "summary": "Remove Users From Group", + "operationId": "remove_users_from_group_api_v1_groups_id__id__users_remove_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserIdsForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/GroupResponse" + }, + { + "type": "null" + } + ], + "title": "Response Remove Users From Group Api V1 Groups Id Id Users Remove Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/groups/id/{id}/delete": { + "delete": { + "tags": [ + "groups" + ], + "summary": "Delete Group By Id", + "operationId": "delete_group_by_id_api_v1_groups_id__id__delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Group By Id Api V1 Groups Id Id Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/files/": { + "post": { + "tags": [ + "files" + ], + "summary": "Upload File", + "operationId": "upload_file_api_v1_files__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "process", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true, + "title": "Process" + } + }, + { + "name": "internal", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Internal" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_file_api_v1_files__post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileModelResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "files" + ], + "summary": "List Files", + "operationId": "list_files_api_v1_files__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "content", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true, + "title": "Content" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileModelResponse" + }, + "title": "Response List Files Api V1 Files Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/files/search": { + "get": { + "tags": [ + "files" + ], + "summary": "Search Files", + "description": "Search for files by filename with support for wildcard patterns.", + "operationId": "search_files_api_v1_files_search_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "filename", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Filename pattern to search for. Supports wildcards such as '*.txt'", + "title": "Filename" + }, + "description": "Filename pattern to search for. Supports wildcards such as '*.txt'" + }, + { + "name": "content", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true, + "title": "Content" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileModelResponse" + }, + "title": "Response Search Files Api V1 Files Search Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/files/all": { + "delete": { + "tags": [ + "files" + ], + "summary": "Delete All Files", + "operationId": "delete_all_files_api_v1_files_all_delete", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/files/{id}": { + "get": { + "tags": [ + "files" + ], + "summary": "Get File By Id", + "operationId": "get_file_by_id_api_v1_files__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileModel" + }, + { + "type": "null" + } + ], + "title": "Response Get File By Id Api V1 Files Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "files" + ], + "summary": "Delete File By Id", + "operationId": "delete_file_by_id_api_v1_files__id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/files/{id}/data/content": { + "get": { + "tags": [ + "files" + ], + "summary": "Get File Data Content By Id", + "operationId": "get_file_data_content_by_id_api_v1_files__id__data_content_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/files/{id}/data/content/update": { + "post": { + "tags": [ + "files" + ], + "summary": "Update File Data Content By Id", + "operationId": "update_file_data_content_by_id_api_v1_files__id__data_content_update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContentForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/files/{id}/content": { + "get": { + "tags": [ + "files" + ], + "summary": "Get File Content By Id", + "operationId": "get_file_content_by_id_api_v1_files__id__content_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "attachment", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Attachment" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/files/{id}/content/html": { + "get": { + "tags": [ + "files" + ], + "summary": "Get Html File Content By Id", + "operationId": "get_html_file_content_by_id_api_v1_files__id__content_html_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/files/{id}/content/{file_name}": { + "get": { + "tags": [ + "files" + ], + "summary": "Get File Content By Id", + "operationId": "get_file_content_by_id_api_v1_files__id__content__file_name__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/": { + "get": { + "tags": [ + "functions" + ], + "summary": "Get Functions", + "operationId": "get_functions_api_v1_functions__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FunctionResponse" + }, + "type": "array", + "title": "Response Get Functions Api V1 Functions Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/functions/export": { + "get": { + "tags": [ + "functions" + ], + "summary": "Get Functions", + "operationId": "get_functions_api_v1_functions_export_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FunctionModel" + }, + "type": "array", + "title": "Response Get Functions Api V1 Functions Export Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/functions/load/url": { + "post": { + "tags": [ + "functions" + ], + "summary": "Load Function From Url", + "operationId": "load_function_from_url_api_v1_functions_load_url_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoadUrlForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Response Load Function From Url Api V1 Functions Load Url Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/functions/sync": { + "post": { + "tags": [ + "functions" + ], + "summary": "Sync Functions", + "operationId": "sync_functions_api_v1_functions_sync_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncFunctionsForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionModel" + }, + { + "type": "null" + } + ], + "title": "Response Sync Functions Api V1 Functions Sync Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/functions/create": { + "post": { + "tags": [ + "functions" + ], + "summary": "Create New Function", + "operationId": "create_new_function_api_v1_functions_create_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FunctionForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionResponse" + }, + { + "type": "null" + } + ], + "title": "Response Create New Function Api V1 Functions Create Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/functions/id/{id}": { + "get": { + "tags": [ + "functions" + ], + "summary": "Get Function By Id", + "operationId": "get_function_by_id_api_v1_functions_id__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionModel" + }, + { + "type": "null" + } + ], + "title": "Response Get Function By Id Api V1 Functions Id Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/toggle": { + "post": { + "tags": [ + "functions" + ], + "summary": "Toggle Function By Id", + "operationId": "toggle_function_by_id_api_v1_functions_id__id__toggle_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionModel" + }, + { + "type": "null" + } + ], + "title": "Response Toggle Function By Id Api V1 Functions Id Id Toggle Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/toggle/global": { + "post": { + "tags": [ + "functions" + ], + "summary": "Toggle Global By Id", + "operationId": "toggle_global_by_id_api_v1_functions_id__id__toggle_global_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionModel" + }, + { + "type": "null" + } + ], + "title": "Response Toggle Global By Id Api V1 Functions Id Id Toggle Global Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/update": { + "post": { + "tags": [ + "functions" + ], + "summary": "Update Function By Id", + "operationId": "update_function_by_id_api_v1_functions_id__id__update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FunctionForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FunctionModel" + }, + { + "type": "null" + } + ], + "title": "Response Update Function By Id Api V1 Functions Id Id Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/delete": { + "delete": { + "tags": [ + "functions" + ], + "summary": "Delete Function By Id", + "operationId": "delete_function_by_id_api_v1_functions_id__id__delete_delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Function By Id Api V1 Functions Id Id Delete Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/valves": { + "get": { + "tags": [ + "functions" + ], + "summary": "Get Function Valves By Id", + "operationId": "get_function_valves_by_id_api_v1_functions_id__id__valves_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Get Function Valves By Id Api V1 Functions Id Id Valves Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/valves/spec": { + "get": { + "tags": [ + "functions" + ], + "summary": "Get Function Valves Spec By Id", + "operationId": "get_function_valves_spec_by_id_api_v1_functions_id__id__valves_spec_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Get Function Valves Spec By Id Api V1 Functions Id Id Valves Spec Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/valves/update": { + "post": { + "tags": [ + "functions" + ], + "summary": "Update Function Valves By Id", + "operationId": "update_function_valves_by_id_api_v1_functions_id__id__valves_update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Update Function Valves By Id Api V1 Functions Id Id Valves Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/valves/user": { + "get": { + "tags": [ + "functions" + ], + "summary": "Get Function User Valves By Id", + "operationId": "get_function_user_valves_by_id_api_v1_functions_id__id__valves_user_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Get Function User Valves By Id Api V1 Functions Id Id Valves User Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/valves/user/spec": { + "get": { + "tags": [ + "functions" + ], + "summary": "Get Function User Valves Spec By Id", + "operationId": "get_function_user_valves_spec_by_id_api_v1_functions_id__id__valves_user_spec_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Get Function User Valves Spec By Id Api V1 Functions Id Id Valves User Spec Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/functions/id/{id}/valves/user/update": { + "post": { + "tags": [ + "functions" + ], + "summary": "Update Function User Valves By Id", + "operationId": "update_function_user_valves_by_id_api_v1_functions_id__id__valves_user_update_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "title": "Response Update Function User Valves By Id Api V1 Functions Id Id Valves User Update Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/evaluations/config": { + "get": { + "tags": [ + "evaluations" + ], + "summary": "Get Config", + "operationId": "get_config_api_v1_evaluations_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "evaluations" + ], + "summary": "Update Config", + "operationId": "update_config_api_v1_evaluations_config_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateConfigForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/evaluations/feedbacks/all": { + "get": { + "tags": [ + "evaluations" + ], + "summary": "Get All Feedbacks", + "operationId": "get_all_feedbacks_api_v1_evaluations_feedbacks_all_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FeedbackUserResponse" + }, + "type": "array", + "title": "Response Get All Feedbacks Api V1 Evaluations Feedbacks All Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "delete": { + "tags": [ + "evaluations" + ], + "summary": "Delete All Feedbacks", + "operationId": "delete_all_feedbacks_api_v1_evaluations_feedbacks_all_delete", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/evaluations/feedbacks/all/export": { + "get": { + "tags": [ + "evaluations" + ], + "summary": "Get All Feedbacks", + "operationId": "get_all_feedbacks_api_v1_evaluations_feedbacks_all_export_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FeedbackModel" + }, + "type": "array", + "title": "Response Get All Feedbacks Api V1 Evaluations Feedbacks All Export Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/evaluations/feedbacks/user": { + "get": { + "tags": [ + "evaluations" + ], + "summary": "Get Feedbacks", + "operationId": "get_feedbacks_api_v1_evaluations_feedbacks_user_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FeedbackUserResponse" + }, + "type": "array", + "title": "Response Get Feedbacks Api V1 Evaluations Feedbacks User Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/evaluations/feedbacks": { + "delete": { + "tags": [ + "evaluations" + ], + "summary": "Delete Feedbacks", + "operationId": "delete_feedbacks_api_v1_evaluations_feedbacks_delete", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Delete Feedbacks Api V1 Evaluations Feedbacks Delete" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/evaluations/feedback": { + "post": { + "tags": [ + "evaluations" + ], + "summary": "Create Feedback", + "operationId": "create_feedback_api_v1_evaluations_feedback_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/evaluations/feedback/{id}": { + "get": { + "tags": [ + "evaluations" + ], + "summary": "Get Feedback By Id", + "operationId": "get_feedback_by_id_api_v1_evaluations_feedback__id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "evaluations" + ], + "summary": "Update Feedback By Id", + "operationId": "update_feedback_by_id_api_v1_evaluations_feedback__id__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "evaluations" + ], + "summary": "Delete Feedback By Id", + "operationId": "delete_feedback_by_id_api_v1_evaluations_feedback__id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/utils/gravatar": { + "get": { + "tags": [ + "utils" + ], + "summary": "Get Gravatar", + "operationId": "get_gravatar_api_v1_utils_gravatar_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "email", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Email" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/utils/code/format": { + "post": { + "tags": [ + "utils" + ], + "summary": "Format Code", + "operationId": "format_code_api_v1_utils_code_format_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodeForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/utils/code/execute": { + "post": { + "tags": [ + "utils" + ], + "summary": "Execute Code", + "operationId": "execute_code_api_v1_utils_code_execute_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodeForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/utils/markdown": { + "post": { + "tags": [ + "utils" + ], + "summary": "Get Html From Markdown", + "operationId": "get_html_from_markdown_api_v1_utils_markdown_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkdownForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/utils/pdf": { + "post": { + "tags": [ + "utils" + ], + "summary": "Download Chat As Pdf", + "operationId": "download_chat_as_pdf_api_v1_utils_pdf_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatTitleMessagesForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/utils/db/download": { + "get": { + "tags": [ + "utils" + ], + "summary": "Download Db", + "operationId": "download_db_api_v1_utils_db_download_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/utils/litellm/config": { + "get": { + "tags": [ + "utils" + ], + "summary": "Download Litellm Config Yaml", + "operationId": "download_litellm_config_yaml_api_v1_utils_litellm_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/models": { + "get": { + "summary": "Get Models", + "operationId": "get_models_api_models_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "refresh", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Refresh" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/models/base": { + "get": { + "summary": "Get Base Models", + "operationId": "get_base_models_api_models_base_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/embeddings": { + "post": { + "summary": "Embeddings", + "description": "OpenAI-compatible embeddings endpoint.\n\nThis handler:\n - Performs user/model checks and dispatches to the correct backend.\n - Supports OpenAI, Ollama, arena models, pipelines, and any compatible provider.\n\nArgs:\n request (Request): Request context.\n form_data (dict): OpenAI-like payload (e.g., {\"model\": \"...\", \"input\": [...]})\n user (UserModel): Authenticated user.\n\nReturns:\n dict: OpenAI-compatible embeddings response.", + "operationId": "embeddings_api_embeddings_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/chat/completions": { + "post": { + "summary": "Chat Completion", + "operationId": "chat_completion_api_chat_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/chat/completed": { + "post": { + "summary": "Chat Completed", + "operationId": "chat_completed_api_chat_completed_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Form Data" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/chat/actions/{action_id}": { + "post": { + "summary": "Chat Action", + "operationId": "chat_action_api_chat_actions__action_id__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "action_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Action Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Form Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/tasks/stop/{task_id}": { + "post": { + "summary": "Stop Task Endpoint", + "operationId": "stop_task_endpoint_api_tasks_stop__task_id__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/tasks": { + "get": { + "summary": "List Tasks Endpoint", + "operationId": "list_tasks_endpoint_api_tasks_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/tasks/chat/{chat_id}": { + "get": { + "summary": "List Tasks By Chat Id Endpoint", + "operationId": "list_tasks_by_chat_id_endpoint_api_tasks_chat__chat_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Chat Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/config": { + "get": { + "summary": "Get App Config", + "operationId": "get_app_config_api_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/webhook": { + "get": { + "summary": "Get Webhook Url", + "operationId": "get_webhook_url_api_webhook_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "summary": "Update Webhook Url", + "operationId": "update_webhook_url_api_webhook_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UrlForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/version": { + "get": { + "summary": "Get App Version", + "operationId": "get_app_version_api_version_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/version/updates": { + "get": { + "summary": "Get App Latest Release Version", + "operationId": "get_app_latest_release_version_api_version_updates_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/changelog": { + "get": { + "summary": "Get App Changelog", + "operationId": "get_app_changelog_api_changelog_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/usage": { + "get": { + "summary": "Get Current Usage", + "description": "Get current usage statistics for Open WebUI.\nThis is an experimental endpoint and subject to change.", + "operationId": "get_current_usage_api_usage_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/oauth/{provider}/login": { + "get": { + "summary": "Oauth Login", + "operationId": "oauth_login_oauth__provider__login_get", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Provider" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/oauth/{provider}/callback": { + "get": { + "summary": "Oauth Callback", + "operationId": "oauth_callback_oauth__provider__callback_get", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Provider" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/manifest.json": { + "get": { + "summary": "Get Manifest Json", + "operationId": "get_manifest_json_manifest_json_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/opensearch.xml": { + "get": { + "summary": "Get Opensearch Xml", + "operationId": "get_opensearch_xml_opensearch_xml_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/health": { + "get": { + "summary": "Healthcheck", + "operationId": "healthcheck_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/health/db": { + "get": { + "summary": "Healthcheck With Db", + "operationId": "healthcheck_with_db_health_db_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/cache/{path}": { + "get": { + "summary": "Serve Cache File", + "operationId": "serve_cache_file_cache__path__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AddMemoryForm": { + "properties": { + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "AddMemoryForm" + }, + "AddPipelineForm": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "urlIdx": { + "type": "integer", + "title": "Urlidx" + } + }, + "type": "object", + "required": [ + "url", + "urlIdx" + ], + "title": "AddPipelineForm" + }, + "AddUserForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "email": { + "type": "string", + "title": "Email" + }, + "password": { + "type": "string", + "title": "Password" + }, + "profile_image_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Profile Image Url", + "default": "/user.png" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Role", + "default": "pending" + } + }, + "type": "object", + "required": [ + "name", + "email", + "password" + ], + "title": "AddUserForm" + }, + "AdminConfig": { + "properties": { + "SHOW_ADMIN_DETAILS": { + "type": "boolean", + "title": "Show Admin Details" + }, + "WEBUI_URL": { + "type": "string", + "title": "Webui Url" + }, + "ENABLE_SIGNUP": { + "type": "boolean", + "title": "Enable Signup" + }, + "ENABLE_API_KEY": { + "type": "boolean", + "title": "Enable Api Key" + }, + "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": { + "type": "boolean", + "title": "Enable Api Key Endpoint Restrictions" + }, + "API_KEY_ALLOWED_ENDPOINTS": { + "type": "string", + "title": "Api Key Allowed Endpoints" + }, + "DEFAULT_USER_ROLE": { + "type": "string", + "title": "Default User Role" + }, + "JWT_EXPIRES_IN": { + "type": "string", + "title": "Jwt Expires In" + }, + "ENABLE_COMMUNITY_SHARING": { + "type": "boolean", + "title": "Enable Community Sharing" + }, + "ENABLE_MESSAGE_RATING": { + "type": "boolean", + "title": "Enable Message Rating" + }, + "ENABLE_CHANNELS": { + "type": "boolean", + "title": "Enable Channels" + }, + "ENABLE_NOTES": { + "type": "boolean", + "title": "Enable Notes" + }, + "ENABLE_USER_WEBHOOKS": { + "type": "boolean", + "title": "Enable User Webhooks" + }, + "PENDING_USER_OVERLAY_TITLE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pending User Overlay Title" + }, + "PENDING_USER_OVERLAY_CONTENT": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pending User Overlay Content" + }, + "RESPONSE_WATERMARK": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response Watermark" + } + }, + "type": "object", + "required": [ + "SHOW_ADMIN_DETAILS", + "WEBUI_URL", + "ENABLE_SIGNUP", + "ENABLE_API_KEY", + "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", + "API_KEY_ALLOWED_ENDPOINTS", + "DEFAULT_USER_ROLE", + "JWT_EXPIRES_IN", + "ENABLE_COMMUNITY_SHARING", + "ENABLE_MESSAGE_RATING", + "ENABLE_CHANNELS", + "ENABLE_NOTES", + "ENABLE_USER_WEBHOOKS" + ], + "title": "AdminConfig" + }, + "ApiKey": { + "properties": { + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + "type": "object", + "title": "ApiKey" + }, + "AudioConfigUpdateForm": { + "properties": { + "tts": { + "$ref": "#/components/schemas/TTSConfigForm" + }, + "stt": { + "$ref": "#/components/schemas/STTConfigForm" + } + }, + "type": "object", + "required": [ + "tts", + "stt" + ], + "title": "AudioConfigUpdateForm" + }, + "Automatic1111ConfigForm": { + "properties": { + "AUTOMATIC1111_BASE_URL": { + "type": "string", + "title": "Automatic1111 Base Url" + }, + "AUTOMATIC1111_API_AUTH": { + "type": "string", + "title": "Automatic1111 Api Auth" + }, + "AUTOMATIC1111_CFG_SCALE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Automatic1111 Cfg Scale" + }, + "AUTOMATIC1111_SAMPLER": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Automatic1111 Sampler" + }, + "AUTOMATIC1111_SCHEDULER": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Automatic1111 Scheduler" + } + }, + "type": "object", + "required": [ + "AUTOMATIC1111_BASE_URL", + "AUTOMATIC1111_API_AUTH", + "AUTOMATIC1111_CFG_SCALE", + "AUTOMATIC1111_SAMPLER", + "AUTOMATIC1111_SCHEDULER" + ], + "title": "Automatic1111ConfigForm" + }, + "AzureOpenAIConfigForm": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "key": { + "type": "string", + "title": "Key" + }, + "version": { + "type": "string", + "title": "Version" + } + }, + "type": "object", + "required": [ + "url", + "key", + "version" + ], + "title": "AzureOpenAIConfigForm" + }, + "BannerModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "type": { + "type": "string", + "title": "Type" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "content": { + "type": "string", + "title": "Content" + }, + "dismissible": { + "type": "boolean", + "title": "Dismissible" + }, + "timestamp": { + "type": "integer", + "title": "Timestamp" + } + }, + "type": "object", + "required": [ + "id", + "type", + "content", + "dismissible", + "timestamp" + ], + "title": "BannerModel" + }, + "BatchProcessFilesForm": { + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/FileModel" + }, + "type": "array", + "title": "Files" + }, + "collection_name": { + "type": "string", + "title": "Collection Name" + } + }, + "type": "object", + "required": [ + "files", + "collection_name" + ], + "title": "BatchProcessFilesForm" + }, + "BatchProcessFilesResponse": { + "properties": { + "results": { + "items": { + "$ref": "#/components/schemas/BatchProcessFilesResult" + }, + "type": "array", + "title": "Results" + }, + "errors": { + "items": { + "$ref": "#/components/schemas/BatchProcessFilesResult" + }, + "type": "array", + "title": "Errors" + } + }, + "type": "object", + "required": [ + "results", + "errors" + ], + "title": "BatchProcessFilesResponse" + }, + "BatchProcessFilesResult": { + "properties": { + "file_id": { + "type": "string", + "title": "File Id" + }, + "status": { + "type": "string", + "title": "Status" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + }, + "type": "object", + "required": [ + "file_id", + "status" + ], + "title": "BatchProcessFilesResult" + }, + "Body_transcription_api_v1_audio_transcriptions_post": { + "properties": { + "file": { + "type": "string", + "format": "binary", + "title": "File" + }, + "language": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Language" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_transcription_api_v1_audio_transcriptions_post" + }, + "Body_upload_file_api_v1_files__post": { + "properties": { + "file": { + "type": "string", + "format": "binary", + "title": "File" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Metadata" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_file_api_v1_files__post" + }, + "Body_upload_model_ollama_models_upload__url_idx__post": { + "properties": { + "file": { + "type": "string", + "format": "binary", + "title": "File" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_model_ollama_models_upload__url_idx__post" + }, + "Body_upload_model_ollama_models_upload_post": { + "properties": { + "file": { + "type": "string", + "format": "binary", + "title": "File" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_model_ollama_models_upload_post" + }, + "Body_upload_pipeline_api_v1_pipelines_upload_post": { + "properties": { + "urlIdx": { + "type": "integer", + "title": "Urlidx" + }, + "file": { + "type": "string", + "format": "binary", + "title": "File" + } + }, + "type": "object", + "required": [ + "urlIdx", + "file" + ], + "title": "Body_upload_pipeline_api_v1_pipelines_upload_post" + }, + "ChannelForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "ChannelForm" + }, + "ChannelModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "created_at", + "updated_at" + ], + "title": "ChannelModel" + }, + "ChatFolderIdForm": { + "properties": { + "folder_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Folder Id" + } + }, + "type": "object", + "title": "ChatFolderIdForm" + }, + "ChatForm": { + "properties": { + "chat": { + "additionalProperties": true, + "type": "object", + "title": "Chat" + }, + "folder_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Folder Id" + } + }, + "type": "object", + "required": [ + "chat" + ], + "title": "ChatForm" + }, + "ChatImportForm": { + "properties": { + "chat": { + "additionalProperties": true, + "type": "object", + "title": "Chat" + }, + "folder_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Folder Id" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta", + "default": {} + }, + "pinned": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Pinned", + "default": false + }, + "created_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "chat" + ], + "title": "ChatImportForm" + }, + "ChatPermissions": { + "properties": { + "controls": { + "type": "boolean", + "title": "Controls", + "default": true + }, + "system_prompt": { + "type": "boolean", + "title": "System Prompt", + "default": true + }, + "file_upload": { + "type": "boolean", + "title": "File Upload", + "default": true + }, + "delete": { + "type": "boolean", + "title": "Delete", + "default": true + }, + "edit": { + "type": "boolean", + "title": "Edit", + "default": true + }, + "share": { + "type": "boolean", + "title": "Share", + "default": true + }, + "export": { + "type": "boolean", + "title": "Export", + "default": true + }, + "stt": { + "type": "boolean", + "title": "Stt", + "default": true + }, + "tts": { + "type": "boolean", + "title": "Tts", + "default": true + }, + "call": { + "type": "boolean", + "title": "Call", + "default": true + }, + "multiple_models": { + "type": "boolean", + "title": "Multiple Models", + "default": true + }, + "temporary": { + "type": "boolean", + "title": "Temporary", + "default": true + }, + "temporary_enforced": { + "type": "boolean", + "title": "Temporary Enforced", + "default": false + } + }, + "type": "object", + "title": "ChatPermissions" + }, + "ChatResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "chat": { + "additionalProperties": true, + "type": "object", + "title": "Chat" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "share_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Share Id" + }, + "archived": { + "type": "boolean", + "title": "Archived" + }, + "pinned": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Pinned", + "default": false + }, + "meta": { + "additionalProperties": true, + "type": "object", + "title": "Meta", + "default": {} + }, + "folder_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Folder Id" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "title", + "chat", + "updated_at", + "created_at", + "archived" + ], + "title": "ChatResponse" + }, + "ChatTitleIdResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "title", + "updated_at", + "created_at" + ], + "title": "ChatTitleIdResponse" + }, + "ChatTitleMessagesForm": { + "properties": { + "title": { + "type": "string", + "title": "Title" + }, + "messages": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Messages" + } + }, + "type": "object", + "required": [ + "title", + "messages" + ], + "title": "ChatTitleMessagesForm" + }, + "CloneForm": { + "properties": { + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "type": "object", + "title": "CloneForm" + }, + "CodeForm": { + "properties": { + "code": { + "type": "string", + "title": "Code" + } + }, + "type": "object", + "required": [ + "code" + ], + "title": "CodeForm" + }, + "CodeInterpreterConfigForm": { + "properties": { + "ENABLE_CODE_EXECUTION": { + "type": "boolean", + "title": "Enable Code Execution" + }, + "CODE_EXECUTION_ENGINE": { + "type": "string", + "title": "Code Execution Engine" + }, + "CODE_EXECUTION_JUPYTER_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code Execution Jupyter Url" + }, + "CODE_EXECUTION_JUPYTER_AUTH": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code Execution Jupyter Auth" + }, + "CODE_EXECUTION_JUPYTER_AUTH_TOKEN": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code Execution Jupyter Auth Token" + }, + "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code Execution Jupyter Auth Password" + }, + "CODE_EXECUTION_JUPYTER_TIMEOUT": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Code Execution Jupyter Timeout" + }, + "ENABLE_CODE_INTERPRETER": { + "type": "boolean", + "title": "Enable Code Interpreter" + }, + "CODE_INTERPRETER_ENGINE": { + "type": "string", + "title": "Code Interpreter Engine" + }, + "CODE_INTERPRETER_PROMPT_TEMPLATE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code Interpreter Prompt Template" + }, + "CODE_INTERPRETER_JUPYTER_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code Interpreter Jupyter Url" + }, + "CODE_INTERPRETER_JUPYTER_AUTH": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code Interpreter Jupyter Auth" + }, + "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code Interpreter Jupyter Auth Token" + }, + "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code Interpreter Jupyter Auth Password" + }, + "CODE_INTERPRETER_JUPYTER_TIMEOUT": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Code Interpreter Jupyter Timeout" + } + }, + "type": "object", + "required": [ + "ENABLE_CODE_EXECUTION", + "CODE_EXECUTION_ENGINE", + "CODE_EXECUTION_JUPYTER_URL", + "CODE_EXECUTION_JUPYTER_AUTH", + "CODE_EXECUTION_JUPYTER_AUTH_TOKEN", + "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", + "CODE_EXECUTION_JUPYTER_TIMEOUT", + "ENABLE_CODE_INTERPRETER", + "CODE_INTERPRETER_ENGINE", + "CODE_INTERPRETER_PROMPT_TEMPLATE", + "CODE_INTERPRETER_JUPYTER_URL", + "CODE_INTERPRETER_JUPYTER_AUTH", + "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN", + "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD", + "CODE_INTERPRETER_JUPYTER_TIMEOUT" + ], + "title": "CodeInterpreterConfigForm" + }, + "ComfyUIConfigForm": { + "properties": { + "COMFYUI_BASE_URL": { + "type": "string", + "title": "Comfyui Base Url" + }, + "COMFYUI_API_KEY": { + "type": "string", + "title": "Comfyui Api Key" + }, + "COMFYUI_WORKFLOW": { + "type": "string", + "title": "Comfyui Workflow" + }, + "COMFYUI_WORKFLOW_NODES": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Comfyui Workflow Nodes" + } + }, + "type": "object", + "required": [ + "COMFYUI_BASE_URL", + "COMFYUI_API_KEY", + "COMFYUI_WORKFLOW", + "COMFYUI_WORKFLOW_NODES" + ], + "title": "ComfyUIConfigForm" + }, + "ConnectionsConfigForm": { + "properties": { + "ENABLE_DIRECT_CONNECTIONS": { + "type": "boolean", + "title": "Enable Direct Connections" + }, + "ENABLE_BASE_MODELS_CACHE": { + "type": "boolean", + "title": "Enable Base Models Cache" + } + }, + "type": "object", + "required": [ + "ENABLE_DIRECT_CONNECTIONS", + "ENABLE_BASE_MODELS_CACHE" + ], + "title": "ConnectionsConfigForm" + }, + "ContentForm": { + "properties": { + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "ContentForm" + }, + "CopyModelForm": { + "properties": { + "source": { + "type": "string", + "title": "Source" + }, + "destination": { + "type": "string", + "title": "Destination" + } + }, + "type": "object", + "required": [ + "source", + "destination" + ], + "title": "CopyModelForm" + }, + "CreateModelForm": { + "properties": { + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "stream": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Stream" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + } + }, + "additionalProperties": true, + "type": "object", + "title": "CreateModelForm" + }, + "DeleteForm": { + "properties": { + "collection_name": { + "type": "string", + "title": "Collection Name" + }, + "file_id": { + "type": "string", + "title": "File Id" + } + }, + "type": "object", + "required": [ + "collection_name", + "file_id" + ], + "title": "DeleteForm" + }, + "DeletePipelineForm": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "urlIdx": { + "type": "integer", + "title": "Urlidx" + } + }, + "type": "object", + "required": [ + "id", + "urlIdx" + ], + "title": "DeletePipelineForm" + }, + "EmbeddingModelUpdateForm": { + "properties": { + "openai_config": { + "anyOf": [ + { + "$ref": "#/components/schemas/open_webui__routers__retrieval__OpenAIConfigForm" + }, + { + "type": "null" + } + ] + }, + "ollama_config": { + "anyOf": [ + { + "$ref": "#/components/schemas/open_webui__routers__retrieval__OllamaConfigForm" + }, + { + "type": "null" + } + ] + }, + "azure_openai_config": { + "anyOf": [ + { + "$ref": "#/components/schemas/AzureOpenAIConfigForm" + }, + { + "type": "null" + } + ] + }, + "embedding_engine": { + "type": "string", + "title": "Embedding Engine" + }, + "embedding_model": { + "type": "string", + "title": "Embedding Model" + }, + "embedding_batch_size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Embedding Batch Size", + "default": 1 + } + }, + "type": "object", + "required": [ + "embedding_engine", + "embedding_model" + ], + "title": "EmbeddingModelUpdateForm" + }, + "EventForm": { + "properties": { + "type": { + "type": "string", + "title": "Type" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data" + } + }, + "type": "object", + "required": [ + "type", + "data" + ], + "title": "EventForm" + }, + "FeaturesPermissions": { + "properties": { + "direct_tool_servers": { + "type": "boolean", + "title": "Direct Tool Servers", + "default": false + }, + "web_search": { + "type": "boolean", + "title": "Web Search", + "default": true + }, + "image_generation": { + "type": "boolean", + "title": "Image Generation", + "default": true + }, + "code_interpreter": { + "type": "boolean", + "title": "Code Interpreter", + "default": true + }, + "notes": { + "type": "boolean", + "title": "Notes", + "default": true + } + }, + "type": "object", + "title": "FeaturesPermissions" + }, + "FeedbackForm": { + "properties": { + "type": { + "type": "string", + "title": "Type" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/RatingData" + }, + { + "type": "null" + } + ] + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "snapshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/SnapshotData" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "type" + ], + "title": "FeedbackForm" + }, + "FeedbackModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "version": { + "type": "integer", + "title": "Version" + }, + "type": { + "type": "string", + "title": "Type" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "snapshot": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Snapshot" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "version", + "type", + "created_at", + "updated_at" + ], + "title": "FeedbackModel" + }, + "FeedbackUserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "version": { + "type": "integer", + "title": "Version" + }, + "type": { + "type": "string", + "title": "Type" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/open_webui__routers__evaluations__UserResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "version", + "type", + "created_at", + "updated_at" + ], + "title": "FeedbackUserResponse" + }, + "FileMeta": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "content_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content Type" + }, + "size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Size" + } + }, + "additionalProperties": true, + "type": "object", + "title": "FileMeta" + }, + "FileMetadataResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "meta": { + "additionalProperties": true, + "type": "object", + "title": "Meta" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "meta", + "created_at", + "updated_at" + ], + "title": "FileMetadataResponse" + }, + "FileModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Hash" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "created_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "filename", + "created_at", + "updated_at" + ], + "title": "FileModel" + }, + "FileModelResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Hash" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "$ref": "#/components/schemas/FileMeta" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "id", + "user_id", + "filename", + "meta", + "created_at", + "updated_at" + ], + "title": "FileModelResponse" + }, + "FolderForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "name" + ], + "title": "FolderForm" + }, + "FolderIsExpandedForm": { + "properties": { + "is_expanded": { + "type": "boolean", + "title": "Is Expanded" + } + }, + "type": "object", + "required": [ + "is_expanded" + ], + "title": "FolderIsExpandedForm" + }, + "FolderModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "items": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Items" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "is_expanded": { + "type": "boolean", + "title": "Is Expanded", + "default": false + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "created_at", + "updated_at" + ], + "title": "FolderModel" + }, + "FolderParentIdForm": { + "properties": { + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + } + }, + "type": "object", + "title": "FolderParentIdForm" + }, + "FunctionForm": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "content": { + "type": "string", + "title": "Content" + }, + "meta": { + "$ref": "#/components/schemas/FunctionMeta" + } + }, + "type": "object", + "required": [ + "id", + "name", + "content", + "meta" + ], + "title": "FunctionForm" + }, + "FunctionMeta": { + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "manifest": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Manifest", + "default": {} + } + }, + "type": "object", + "title": "FunctionMeta" + }, + "FunctionModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "type": { + "type": "string", + "title": "Type" + }, + "content": { + "type": "string", + "title": "Content" + }, + "meta": { + "$ref": "#/components/schemas/FunctionMeta" + }, + "is_active": { + "type": "boolean", + "title": "Is Active", + "default": false + }, + "is_global": { + "type": "boolean", + "title": "Is Global", + "default": false + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "type", + "content", + "meta", + "updated_at", + "created_at" + ], + "title": "FunctionModel" + }, + "FunctionResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "type": { + "type": "string", + "title": "Type" + }, + "name": { + "type": "string", + "title": "Name" + }, + "meta": { + "$ref": "#/components/schemas/FunctionMeta" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "is_global": { + "type": "boolean", + "title": "Is Global" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "type", + "name", + "meta", + "is_active", + "is_global", + "updated_at", + "created_at" + ], + "title": "FunctionResponse" + }, + "GeminiConfigForm": { + "properties": { + "GEMINI_API_BASE_URL": { + "type": "string", + "title": "Gemini Api Base Url" + }, + "GEMINI_API_KEY": { + "type": "string", + "title": "Gemini Api Key" + } + }, + "type": "object", + "required": [ + "GEMINI_API_BASE_URL", + "GEMINI_API_KEY" + ], + "title": "GeminiConfigForm" + }, + "GenerateCompletionForm": { + "properties": { + "model": { + "type": "string", + "title": "Model" + }, + "prompt": { + "type": "string", + "title": "Prompt" + }, + "suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Suffix" + }, + "images": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Images" + }, + "format": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + }, + "options": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Options" + }, + "system": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "System" + }, + "template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Template" + }, + "context": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Context" + }, + "stream": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Stream", + "default": true + }, + "raw": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Raw" + }, + "keep_alive": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keep Alive" + } + }, + "type": "object", + "required": [ + "model", + "prompt" + ], + "title": "GenerateCompletionForm" + }, + "GenerateEmbedForm": { + "properties": { + "model": { + "type": "string", + "title": "Model" + }, + "input": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "title": "Input" + }, + "truncate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Truncate" + }, + "options": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Options" + }, + "keep_alive": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keep Alive" + } + }, + "type": "object", + "required": [ + "model", + "input" + ], + "title": "GenerateEmbedForm" + }, + "GenerateEmbeddingsForm": { + "properties": { + "model": { + "type": "string", + "title": "Model" + }, + "prompt": { + "type": "string", + "title": "Prompt" + }, + "options": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Options" + }, + "keep_alive": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keep Alive" + } + }, + "type": "object", + "required": [ + "model", + "prompt" + ], + "title": "GenerateEmbeddingsForm" + }, + "GenerateImageForm": { + "properties": { + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "prompt": { + "type": "string", + "title": "Prompt" + }, + "size": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Size" + }, + "n": { + "type": "integer", + "title": "N", + "default": 1 + }, + "negative_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Negative Prompt" + } + }, + "type": "object", + "required": [ + "prompt" + ], + "title": "GenerateImageForm" + }, + "GroupForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "permissions": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Permissions" + } + }, + "type": "object", + "required": [ + "name", + "description" + ], + "title": "GroupForm" + }, + "GroupResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "permissions": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Permissions" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "user_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "User Ids", + "default": [] + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "description", + "created_at", + "updated_at" + ], + "title": "GroupResponse" + }, + "GroupUpdateForm": { + "properties": { + "user_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "User Ids" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "permissions": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Permissions" + } + }, + "type": "object", + "required": [ + "name", + "description" + ], + "title": "GroupUpdateForm" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "ImageConfigForm": { + "properties": { + "MODEL": { + "type": "string", + "title": "Model" + }, + "IMAGE_SIZE": { + "type": "string", + "title": "Image Size" + }, + "IMAGE_STEPS": { + "type": "integer", + "title": "Image Steps" + } + }, + "type": "object", + "required": [ + "MODEL", + "IMAGE_SIZE", + "IMAGE_STEPS" + ], + "title": "ImageConfigForm" + }, + "ImportConfigForm": { + "properties": { + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + } + }, + "type": "object", + "required": [ + "config" + ], + "title": "ImportConfigForm" + }, + "KnowledgeFileIdForm": { + "properties": { + "file_id": { + "type": "string", + "title": "File Id" + } + }, + "type": "object", + "required": [ + "file_id" + ], + "title": "KnowledgeFileIdForm" + }, + "KnowledgeFilesResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "files": { + "items": { + "$ref": "#/components/schemas/FileMetadataResponse" + }, + "type": "array", + "title": "Files" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "description", + "created_at", + "updated_at", + "files" + ], + "title": "KnowledgeFilesResponse" + }, + "KnowledgeForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + } + }, + "type": "object", + "required": [ + "name", + "description" + ], + "title": "KnowledgeForm" + }, + "KnowledgeResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "files": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileMetadataResponse" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Files" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "description", + "created_at", + "updated_at" + ], + "title": "KnowledgeResponse" + }, + "KnowledgeUserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/open_webui__models__users__UserResponse" + }, + { + "type": "null" + } + ] + }, + "files": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileMetadataResponse" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Files" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "description", + "created_at", + "updated_at" + ], + "title": "KnowledgeUserResponse" + }, + "LdapConfigForm": { + "properties": { + "enable_ldap": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Ldap" + } + }, + "type": "object", + "title": "LdapConfigForm" + }, + "LdapForm": { + "properties": { + "user": { + "type": "string", + "title": "User" + }, + "password": { + "type": "string", + "title": "Password" + } + }, + "type": "object", + "required": [ + "user", + "password" + ], + "title": "LdapForm" + }, + "LdapServerConfig": { + "properties": { + "label": { + "type": "string", + "title": "Label" + }, + "host": { + "type": "string", + "title": "Host" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Port" + }, + "attribute_for_mail": { + "type": "string", + "title": "Attribute For Mail", + "default": "mail" + }, + "attribute_for_username": { + "type": "string", + "title": "Attribute For Username", + "default": "uid" + }, + "app_dn": { + "type": "string", + "title": "App Dn" + }, + "app_dn_password": { + "type": "string", + "title": "App Dn Password" + }, + "search_base": { + "type": "string", + "title": "Search Base" + }, + "search_filters": { + "type": "string", + "title": "Search Filters", + "default": "" + }, + "use_tls": { + "type": "boolean", + "title": "Use Tls", + "default": true + }, + "certificate_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Certificate Path" + }, + "validate_cert": { + "type": "boolean", + "title": "Validate Cert", + "default": true + }, + "ciphers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ciphers", + "default": "ALL" + } + }, + "type": "object", + "required": [ + "label", + "host", + "app_dn", + "app_dn_password", + "search_base" + ], + "title": "LdapServerConfig" + }, + "LoadUrlForm": { + "properties": { + "url": { + "type": "string", + "maxLength": 2083, + "minLength": 1, + "format": "uri", + "title": "Url" + } + }, + "type": "object", + "required": [ + "url" + ], + "title": "LoadUrlForm" + }, + "MarkdownForm": { + "properties": { + "md": { + "type": "string", + "title": "Md" + } + }, + "type": "object", + "required": [ + "md" + ], + "title": "MarkdownForm" + }, + "MemoryModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "content": { + "type": "string", + "title": "Content" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "content", + "updated_at", + "created_at" + ], + "title": "MemoryModel" + }, + "MemoryUpdateModel": { + "properties": { + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content" + } + }, + "type": "object", + "title": "MemoryUpdateModel" + }, + "MessageModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "channel_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Channel Id" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + }, + "content": { + "type": "string", + "title": "Content" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "content", + "created_at", + "updated_at" + ], + "title": "MessageModel" + }, + "MessageUserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "channel_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Channel Id" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + }, + "content": { + "type": "string", + "title": "Content" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "latest_reply_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Latest Reply At" + }, + "reply_count": { + "type": "integer", + "title": "Reply Count" + }, + "reactions": { + "items": { + "$ref": "#/components/schemas/Reactions" + }, + "type": "array", + "title": "Reactions" + }, + "user": { + "$ref": "#/components/schemas/UserNameResponse" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "content", + "created_at", + "updated_at", + "latest_reply_at", + "reply_count", + "reactions", + "user" + ], + "title": "MessageUserResponse" + }, + "ModelForm": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "base_model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Model Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "meta": { + "$ref": "#/components/schemas/ModelMeta" + }, + "params": { + "$ref": "#/components/schemas/ModelParams" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "is_active": { + "type": "boolean", + "title": "Is Active", + "default": true + } + }, + "type": "object", + "required": [ + "id", + "name", + "meta", + "params" + ], + "title": "ModelForm" + }, + "ModelMeta": { + "properties": { + "profile_image_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Profile Image Url", + "default": "/static/favicon.png" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "capabilities": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Capabilities" + } + }, + "additionalProperties": true, + "type": "object", + "title": "ModelMeta" + }, + "ModelModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "base_model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Model Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "params": { + "$ref": "#/components/schemas/ModelParams" + }, + "meta": { + "$ref": "#/components/schemas/ModelMeta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "params", + "meta", + "is_active", + "updated_at", + "created_at" + ], + "title": "ModelModel" + }, + "ModelNameForm": { + "properties": { + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + "additionalProperties": true, + "type": "object", + "title": "ModelNameForm" + }, + "ModelParams": { + "properties": {}, + "additionalProperties": true, + "type": "object", + "title": "ModelParams" + }, + "ModelResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "base_model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Model Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "params": { + "$ref": "#/components/schemas/ModelParams" + }, + "meta": { + "$ref": "#/components/schemas/ModelMeta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "params", + "meta", + "is_active", + "updated_at", + "created_at" + ], + "title": "ModelResponse" + }, + "ModelUserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "base_model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Model Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "params": { + "$ref": "#/components/schemas/ModelParams" + }, + "meta": { + "$ref": "#/components/schemas/ModelMeta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/open_webui__models__users__UserResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "params", + "meta", + "is_active", + "updated_at", + "created_at" + ], + "title": "ModelUserResponse" + }, + "ModelsConfigForm": { + "properties": { + "DEFAULT_MODELS": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Default Models" + }, + "MODEL_ORDER_LIST": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Model Order List" + } + }, + "type": "object", + "required": [ + "DEFAULT_MODELS", + "MODEL_ORDER_LIST" + ], + "title": "ModelsConfigForm" + }, + "NoteForm": { + "properties": { + "title": { + "type": "string", + "title": "Title" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + } + }, + "type": "object", + "required": [ + "title" + ], + "title": "NoteForm" + }, + "NoteModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "title", + "created_at", + "updated_at" + ], + "title": "NoteModel" + }, + "NoteTitleIdResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "title", + "updated_at", + "created_at" + ], + "title": "NoteTitleIdResponse" + }, + "NoteUserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/open_webui__models__users__UserResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "title", + "created_at", + "updated_at" + ], + "title": "NoteUserResponse" + }, + "ProcessFileForm": { + "properties": { + "file_id": { + "type": "string", + "title": "File Id" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content" + }, + "collection_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Collection Name" + } + }, + "type": "object", + "required": [ + "file_id" + ], + "title": "ProcessFileForm" + }, + "ProcessTextForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "content": { + "type": "string", + "title": "Content" + }, + "collection_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Collection Name" + } + }, + "type": "object", + "required": [ + "name", + "content" + ], + "title": "ProcessTextForm" + }, + "ProcessUrlForm": { + "properties": { + "collection_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Collection Name" + }, + "url": { + "type": "string", + "title": "Url" + } + }, + "type": "object", + "required": [ + "url" + ], + "title": "ProcessUrlForm" + }, + "PromptForm": { + "properties": { + "command": { + "type": "string", + "title": "Command" + }, + "title": { + "type": "string", + "title": "Title" + }, + "content": { + "type": "string", + "title": "Content" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + } + }, + "type": "object", + "required": [ + "command", + "title", + "content" + ], + "title": "PromptForm" + }, + "PromptModel": { + "properties": { + "command": { + "type": "string", + "title": "Command" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "content": { + "type": "string", + "title": "Content" + }, + "timestamp": { + "type": "integer", + "title": "Timestamp" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + } + }, + "type": "object", + "required": [ + "command", + "user_id", + "title", + "content", + "timestamp" + ], + "title": "PromptModel" + }, + "PromptSuggestion": { + "properties": { + "title": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Title" + }, + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": [ + "title", + "content" + ], + "title": "PromptSuggestion" + }, + "PromptUserResponse": { + "properties": { + "command": { + "type": "string", + "title": "Command" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "content": { + "type": "string", + "title": "Content" + }, + "timestamp": { + "type": "integer", + "title": "Timestamp" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/open_webui__models__users__UserResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "command", + "user_id", + "title", + "content", + "timestamp" + ], + "title": "PromptUserResponse" + }, + "PushModelForm": { + "properties": { + "model": { + "type": "string", + "title": "Model" + }, + "insecure": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Insecure" + }, + "stream": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Stream" + } + }, + "type": "object", + "required": [ + "model" + ], + "title": "PushModelForm" + }, + "QueryCollectionsForm": { + "properties": { + "collection_names": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Collection Names" + }, + "query": { + "type": "string", + "title": "Query" + }, + "k": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "K" + }, + "k_reranker": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "K Reranker" + }, + "r": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "R" + }, + "hybrid": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Hybrid" + }, + "hybrid_bm25_weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Hybrid Bm25 Weight" + } + }, + "type": "object", + "required": [ + "collection_names", + "query" + ], + "title": "QueryCollectionsForm" + }, + "QueryDocForm": { + "properties": { + "collection_name": { + "type": "string", + "title": "Collection Name" + }, + "query": { + "type": "string", + "title": "Query" + }, + "k": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "K" + }, + "k_reranker": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "K Reranker" + }, + "r": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "R" + }, + "hybrid": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Hybrid" + } + }, + "type": "object", + "required": [ + "collection_name", + "query" + ], + "title": "QueryDocForm" + }, + "QueryMemoryForm": { + "properties": { + "content": { + "type": "string", + "title": "Content" + }, + "k": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "K", + "default": 1 + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "QueryMemoryForm" + }, + "RatingData": { + "properties": { + "rating": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rating" + }, + "model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model Id" + }, + "sibling_model_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sibling Model Ids" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Comment" + } + }, + "additionalProperties": true, + "type": "object", + "title": "RatingData" + }, + "ReactionForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "ReactionForm" + }, + "Reactions": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "user_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "User Ids" + }, + "count": { + "type": "integer", + "title": "Count" + } + }, + "type": "object", + "required": [ + "name", + "user_ids", + "count" + ], + "title": "Reactions" + }, + "STTConfigForm": { + "properties": { + "OPENAI_API_BASE_URL": { + "type": "string", + "title": "Openai Api Base Url" + }, + "OPENAI_API_KEY": { + "type": "string", + "title": "Openai Api Key" + }, + "ENGINE": { + "type": "string", + "title": "Engine" + }, + "MODEL": { + "type": "string", + "title": "Model" + }, + "SUPPORTED_CONTENT_TYPES": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Supported Content Types", + "default": [] + }, + "WHISPER_MODEL": { + "type": "string", + "title": "Whisper Model" + }, + "DEEPGRAM_API_KEY": { + "type": "string", + "title": "Deepgram Api Key" + }, + "AZURE_API_KEY": { + "type": "string", + "title": "Azure Api Key" + }, + "AZURE_REGION": { + "type": "string", + "title": "Azure Region" + }, + "AZURE_LOCALES": { + "type": "string", + "title": "Azure Locales" + }, + "AZURE_BASE_URL": { + "type": "string", + "title": "Azure Base Url" + }, + "AZURE_MAX_SPEAKERS": { + "type": "string", + "title": "Azure Max Speakers" + } + }, + "type": "object", + "required": [ + "OPENAI_API_BASE_URL", + "OPENAI_API_KEY", + "ENGINE", + "MODEL", + "WHISPER_MODEL", + "DEEPGRAM_API_KEY", + "AZURE_API_KEY", + "AZURE_REGION", + "AZURE_LOCALES", + "AZURE_BASE_URL", + "AZURE_MAX_SPEAKERS" + ], + "title": "STTConfigForm" + }, + "SearchForm": { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Queries" + } + }, + "type": "object", + "required": [ + "queries" + ], + "title": "SearchForm" + }, + "SessionUserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "email": { + "type": "string", + "title": "Email" + }, + "name": { + "type": "string", + "title": "Name" + }, + "role": { + "type": "string", + "title": "Role" + }, + "profile_image_url": { + "type": "string", + "title": "Profile Image Url" + }, + "token": { + "type": "string", + "title": "Token" + }, + "token_type": { + "type": "string", + "title": "Token Type" + }, + "expires_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "permissions": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Permissions" + } + }, + "type": "object", + "required": [ + "id", + "email", + "name", + "role", + "profile_image_url", + "token", + "token_type" + ], + "title": "SessionUserResponse" + }, + "SetBannersForm": { + "properties": { + "banners": { + "items": { + "$ref": "#/components/schemas/BannerModel" + }, + "type": "array", + "title": "Banners" + } + }, + "type": "object", + "required": [ + "banners" + ], + "title": "SetBannersForm" + }, + "SetDefaultSuggestionsForm": { + "properties": { + "suggestions": { + "items": { + "$ref": "#/components/schemas/PromptSuggestion" + }, + "type": "array", + "title": "Suggestions" + } + }, + "type": "object", + "required": [ + "suggestions" + ], + "title": "SetDefaultSuggestionsForm" + }, + "SharingPermissions": { + "properties": { + "public_models": { + "type": "boolean", + "title": "Public Models", + "default": true + }, + "public_knowledge": { + "type": "boolean", + "title": "Public Knowledge", + "default": true + }, + "public_prompts": { + "type": "boolean", + "title": "Public Prompts", + "default": true + }, + "public_tools": { + "type": "boolean", + "title": "Public Tools", + "default": true + } + }, + "type": "object", + "title": "SharingPermissions" + }, + "SigninForm": { + "properties": { + "email": { + "type": "string", + "title": "Email" + }, + "password": { + "type": "string", + "title": "Password" + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "title": "SigninForm" + }, + "SigninResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "email": { + "type": "string", + "title": "Email" + }, + "name": { + "type": "string", + "title": "Name" + }, + "role": { + "type": "string", + "title": "Role" + }, + "profile_image_url": { + "type": "string", + "title": "Profile Image Url" + }, + "token": { + "type": "string", + "title": "Token" + }, + "token_type": { + "type": "string", + "title": "Token Type" + } + }, + "type": "object", + "required": [ + "id", + "email", + "name", + "role", + "profile_image_url", + "token", + "token_type" + ], + "title": "SigninResponse" + }, + "SignupForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "email": { + "type": "string", + "title": "Email" + }, + "password": { + "type": "string", + "title": "Password" + }, + "profile_image_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Profile Image Url", + "default": "/user.png" + } + }, + "type": "object", + "required": [ + "name", + "email", + "password" + ], + "title": "SignupForm" + }, + "SnapshotData": { + "properties": { + "chat": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Chat" + } + }, + "additionalProperties": true, + "type": "object", + "title": "SnapshotData" + }, + "SyncFunctionsForm": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "content": { + "type": "string", + "title": "Content" + }, + "meta": { + "$ref": "#/components/schemas/FunctionMeta" + }, + "functions": { + "items": { + "$ref": "#/components/schemas/FunctionModel" + }, + "type": "array", + "title": "Functions", + "default": [] + } + }, + "type": "object", + "required": [ + "id", + "name", + "content", + "meta" + ], + "title": "SyncFunctionsForm" + }, + "TTSConfigForm": { + "properties": { + "OPENAI_API_BASE_URL": { + "type": "string", + "title": "Openai Api Base Url" + }, + "OPENAI_API_KEY": { + "type": "string", + "title": "Openai Api Key" + }, + "API_KEY": { + "type": "string", + "title": "Api Key" + }, + "ENGINE": { + "type": "string", + "title": "Engine" + }, + "MODEL": { + "type": "string", + "title": "Model" + }, + "VOICE": { + "type": "string", + "title": "Voice" + }, + "SPLIT_ON": { + "type": "string", + "title": "Split On" + }, + "AZURE_SPEECH_REGION": { + "type": "string", + "title": "Azure Speech Region" + }, + "AZURE_SPEECH_BASE_URL": { + "type": "string", + "title": "Azure Speech Base Url" + }, + "AZURE_SPEECH_OUTPUT_FORMAT": { + "type": "string", + "title": "Azure Speech Output Format" + } + }, + "type": "object", + "required": [ + "OPENAI_API_BASE_URL", + "OPENAI_API_KEY", + "API_KEY", + "ENGINE", + "MODEL", + "VOICE", + "SPLIT_ON", + "AZURE_SPEECH_REGION", + "AZURE_SPEECH_BASE_URL", + "AZURE_SPEECH_OUTPUT_FORMAT" + ], + "title": "TTSConfigForm" + }, + "TagFilterForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "skip": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Skip", + "default": 0 + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit", + "default": 50 + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "TagFilterForm" + }, + "TagForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "TagForm" + }, + "TagModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + } + }, + "type": "object", + "required": [ + "id", + "name", + "user_id" + ], + "title": "TagModel" + }, + "TaskConfigForm": { + "properties": { + "TASK_MODEL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Model" + }, + "TASK_MODEL_EXTERNAL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Model External" + }, + "ENABLE_TITLE_GENERATION": { + "type": "boolean", + "title": "Enable Title Generation" + }, + "TITLE_GENERATION_PROMPT_TEMPLATE": { + "type": "string", + "title": "Title Generation Prompt Template" + }, + "IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": { + "type": "string", + "title": "Image Prompt Generation Prompt Template" + }, + "ENABLE_AUTOCOMPLETE_GENERATION": { + "type": "boolean", + "title": "Enable Autocomplete Generation" + }, + "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": { + "type": "integer", + "title": "Autocomplete Generation Input Max Length" + }, + "TAGS_GENERATION_PROMPT_TEMPLATE": { + "type": "string", + "title": "Tags Generation Prompt Template" + }, + "FOLLOW_UP_GENERATION_PROMPT_TEMPLATE": { + "type": "string", + "title": "Follow Up Generation Prompt Template" + }, + "ENABLE_FOLLOW_UP_GENERATION": { + "type": "boolean", + "title": "Enable Follow Up Generation" + }, + "ENABLE_TAGS_GENERATION": { + "type": "boolean", + "title": "Enable Tags Generation" + }, + "ENABLE_SEARCH_QUERY_GENERATION": { + "type": "boolean", + "title": "Enable Search Query Generation" + }, + "ENABLE_RETRIEVAL_QUERY_GENERATION": { + "type": "boolean", + "title": "Enable Retrieval Query Generation" + }, + "QUERY_GENERATION_PROMPT_TEMPLATE": { + "type": "string", + "title": "Query Generation Prompt Template" + }, + "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": { + "type": "string", + "title": "Tools Function Calling Prompt Template" + } + }, + "type": "object", + "required": [ + "TASK_MODEL", + "TASK_MODEL_EXTERNAL", + "ENABLE_TITLE_GENERATION", + "TITLE_GENERATION_PROMPT_TEMPLATE", + "IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE", + "ENABLE_AUTOCOMPLETE_GENERATION", + "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH", + "TAGS_GENERATION_PROMPT_TEMPLATE", + "FOLLOW_UP_GENERATION_PROMPT_TEMPLATE", + "ENABLE_FOLLOW_UP_GENERATION", + "ENABLE_TAGS_GENERATION", + "ENABLE_SEARCH_QUERY_GENERATION", + "ENABLE_RETRIEVAL_QUERY_GENERATION", + "QUERY_GENERATION_PROMPT_TEMPLATE", + "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE" + ], + "title": "TaskConfigForm" + }, + "ToolForm": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "content": { + "type": "string", + "title": "Content" + }, + "meta": { + "$ref": "#/components/schemas/ToolMeta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + } + }, + "type": "object", + "required": [ + "id", + "name", + "content", + "meta" + ], + "title": "ToolForm" + }, + "ToolMeta": { + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "manifest": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Manifest", + "default": {} + } + }, + "type": "object", + "title": "ToolMeta" + }, + "ToolModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "content": { + "type": "string", + "title": "Content" + }, + "specs": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Specs" + }, + "meta": { + "$ref": "#/components/schemas/ToolMeta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "content", + "specs", + "meta", + "updated_at", + "created_at" + ], + "title": "ToolModel" + }, + "ToolResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "meta": { + "$ref": "#/components/schemas/ToolMeta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "meta", + "updated_at", + "created_at" + ], + "title": "ToolResponse" + }, + "ToolServerConnection": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "path": { + "type": "string", + "title": "Path" + }, + "auth_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Auth Type" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Key" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Config" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "url", + "path", + "auth_type", + "key", + "config" + ], + "title": "ToolServerConnection" + }, + "ToolServersConfigForm": { + "properties": { + "TOOL_SERVER_CONNECTIONS": { + "items": { + "$ref": "#/components/schemas/ToolServerConnection" + }, + "type": "array", + "title": "Tool Server Connections" + } + }, + "type": "object", + "required": [ + "TOOL_SERVER_CONNECTIONS" + ], + "title": "ToolServersConfigForm" + }, + "ToolUserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "user_id": { + "type": "string", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "meta": { + "$ref": "#/components/schemas/ToolMeta" + }, + "access_control": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Control" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/open_webui__models__users__UserResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "name", + "meta", + "updated_at", + "created_at" + ], + "title": "ToolUserResponse" + }, + "UpdateConfigForm": { + "properties": { + "ENABLE_EVALUATION_ARENA_MODELS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Evaluation Arena Models" + }, + "EVALUATION_ARENA_MODELS": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Evaluation Arena Models" + } + }, + "type": "object", + "title": "UpdateConfigForm" + }, + "UpdatePasswordForm": { + "properties": { + "password": { + "type": "string", + "title": "Password" + }, + "new_password": { + "type": "string", + "title": "New Password" + } + }, + "type": "object", + "required": [ + "password", + "new_password" + ], + "title": "UpdatePasswordForm" + }, + "UpdateProfileForm": { + "properties": { + "profile_image_url": { + "type": "string", + "title": "Profile Image Url" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "profile_image_url", + "name" + ], + "title": "UpdateProfileForm" + }, + "UrlForm": { + "properties": { + "url": { + "type": "string", + "title": "Url" + } + }, + "type": "object", + "required": [ + "url" + ], + "title": "UrlForm" + }, + "UserIdsForm": { + "properties": { + "user_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "User Ids" + } + }, + "type": "object", + "title": "UserIdsForm" + }, + "UserInfoListResponse": { + "properties": { + "users": { + "items": { + "$ref": "#/components/schemas/UserInfoResponse" + }, + "type": "array", + "title": "Users" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "users", + "total" + ], + "title": "UserInfoListResponse" + }, + "UserInfoResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "email": { + "type": "string", + "title": "Email" + }, + "role": { + "type": "string", + "title": "Role" + } + }, + "type": "object", + "required": [ + "id", + "name", + "email", + "role" + ], + "title": "UserInfoResponse" + }, + "UserListResponse": { + "properties": { + "users": { + "items": { + "$ref": "#/components/schemas/UserModel" + }, + "type": "array", + "title": "Users" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "users", + "total" + ], + "title": "UserListResponse" + }, + "UserModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "email": { + "type": "string", + "title": "Email" + }, + "role": { + "type": "string", + "title": "Role", + "default": "pending" + }, + "profile_image_url": { + "type": "string", + "title": "Profile Image Url" + }, + "last_active_at": { + "type": "integer", + "title": "Last Active At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + }, + "settings": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserSettings" + }, + { + "type": "null" + } + ] + }, + "info": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Info" + }, + "oauth_sub": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Oauth Sub" + } + }, + "type": "object", + "required": [ + "id", + "name", + "email", + "profile_image_url", + "last_active_at", + "updated_at", + "created_at" + ], + "title": "UserModel" + }, + "UserNameResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "role": { + "type": "string", + "title": "Role" + }, + "profile_image_url": { + "type": "string", + "title": "Profile Image Url" + } + }, + "type": "object", + "required": [ + "id", + "name", + "role", + "profile_image_url" + ], + "title": "UserNameResponse" + }, + "UserPermissions": { + "properties": { + "workspace": { + "$ref": "#/components/schemas/WorkspacePermissions" + }, + "sharing": { + "$ref": "#/components/schemas/SharingPermissions" + }, + "chat": { + "$ref": "#/components/schemas/ChatPermissions" + }, + "features": { + "$ref": "#/components/schemas/FeaturesPermissions" + } + }, + "type": "object", + "required": [ + "workspace", + "sharing", + "chat", + "features" + ], + "title": "UserPermissions" + }, + "UserSettings": { + "properties": { + "ui": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Ui", + "default": {} + } + }, + "additionalProperties": true, + "type": "object", + "title": "UserSettings" + }, + "UserUpdateForm": { + "properties": { + "role": { + "type": "string", + "title": "Role" + }, + "name": { + "type": "string", + "title": "Name" + }, + "email": { + "type": "string", + "title": "Email" + }, + "profile_image_url": { + "type": "string", + "title": "Profile Image Url" + }, + "password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Password" + } + }, + "type": "object", + "required": [ + "role", + "name", + "email", + "profile_image_url" + ], + "title": "UserUpdateForm" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "WebConfig": { + "properties": { + "ENABLE_WEB_SEARCH": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Web Search" + }, + "WEB_SEARCH_ENGINE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Web Search Engine" + }, + "WEB_SEARCH_TRUST_ENV": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Web Search Trust Env" + }, + "WEB_SEARCH_RESULT_COUNT": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Web Search Result Count" + }, + "WEB_SEARCH_CONCURRENT_REQUESTS": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Web Search Concurrent Requests" + }, + "WEB_SEARCH_DOMAIN_FILTER_LIST": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Web Search Domain Filter List", + "default": [] + }, + "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Bypass Web Search Embedding And Retrieval" + }, + "BYPASS_WEB_SEARCH_WEB_LOADER": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Bypass Web Search Web Loader" + }, + "SEARXNG_QUERY_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Searxng Query Url" + }, + "YACY_QUERY_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Yacy Query Url" + }, + "YACY_USERNAME": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Yacy Username" + }, + "YACY_PASSWORD": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Yacy Password" + }, + "GOOGLE_PSE_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Google Pse Api Key" + }, + "GOOGLE_PSE_ENGINE_ID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Google Pse Engine Id" + }, + "BRAVE_SEARCH_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Brave Search Api Key" + }, + "KAGI_SEARCH_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Kagi Search Api Key" + }, + "MOJEEK_SEARCH_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mojeek Search Api Key" + }, + "BOCHA_SEARCH_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bocha Search Api Key" + }, + "SERPSTACK_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Serpstack Api Key" + }, + "SERPSTACK_HTTPS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Serpstack Https" + }, + "SERPER_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Serper Api Key" + }, + "SERPLY_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Serply Api Key" + }, + "TAVILY_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tavily Api Key" + }, + "SEARCHAPI_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Searchapi Api Key" + }, + "SEARCHAPI_ENGINE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Searchapi Engine" + }, + "SERPAPI_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Serpapi Api Key" + }, + "SERPAPI_ENGINE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Serpapi Engine" + }, + "JINA_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jina Api Key" + }, + "BING_SEARCH_V7_ENDPOINT": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bing Search V7 Endpoint" + }, + "BING_SEARCH_V7_SUBSCRIPTION_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bing Search V7 Subscription Key" + }, + "EXA_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exa Api Key" + }, + "PERPLEXITY_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Perplexity Api Key" + }, + "PERPLEXITY_MODEL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Perplexity Model" + }, + "PERPLEXITY_SEARCH_CONTEXT_USAGE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Perplexity Search Context Usage" + }, + "SOUGOU_API_SID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sougou Api Sid" + }, + "SOUGOU_API_SK": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sougou Api Sk" + }, + "WEB_LOADER_ENGINE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Web Loader Engine" + }, + "ENABLE_WEB_LOADER_SSL_VERIFICATION": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Web Loader Ssl Verification" + }, + "PLAYWRIGHT_WS_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Playwright Ws Url" + }, + "PLAYWRIGHT_TIMEOUT": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Playwright Timeout" + }, + "FIRECRAWL_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Firecrawl Api Key" + }, + "FIRECRAWL_API_BASE_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Firecrawl Api Base Url" + }, + "TAVILY_EXTRACT_DEPTH": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tavily Extract Depth" + }, + "EXTERNAL_WEB_SEARCH_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Web Search Url" + }, + "EXTERNAL_WEB_SEARCH_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Web Search Api Key" + }, + "EXTERNAL_WEB_LOADER_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Web Loader Url" + }, + "EXTERNAL_WEB_LOADER_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Web Loader Api Key" + }, + "YOUTUBE_LOADER_LANGUAGE": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Youtube Loader Language" + }, + "YOUTUBE_LOADER_PROXY_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Youtube Loader Proxy Url" + }, + "YOUTUBE_LOADER_TRANSLATION": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Youtube Loader Translation" + } + }, + "type": "object", + "title": "WebConfig" + }, + "WorkspacePermissions": { + "properties": { + "models": { + "type": "boolean", + "title": "Models", + "default": false + }, + "knowledge": { + "type": "boolean", + "title": "Knowledge", + "default": false + }, + "prompts": { + "type": "boolean", + "title": "Prompts", + "default": false + }, + "tools": { + "type": "boolean", + "title": "Tools", + "default": false + } + }, + "type": "object", + "title": "WorkspacePermissions" + }, + "open_webui__models__auths__UserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "email": { + "type": "string", + "title": "Email" + }, + "name": { + "type": "string", + "title": "Name" + }, + "role": { + "type": "string", + "title": "Role" + }, + "profile_image_url": { + "type": "string", + "title": "Profile Image Url" + } + }, + "type": "object", + "required": [ + "id", + "email", + "name", + "role", + "profile_image_url" + ], + "title": "UserResponse" + }, + "open_webui__models__messages__MessageForm": { + "properties": { + "content": { + "type": "string", + "title": "Content" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "MessageForm" + }, + "open_webui__models__users__UserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "email": { + "type": "string", + "title": "Email" + }, + "role": { + "type": "string", + "title": "Role" + }, + "profile_image_url": { + "type": "string", + "title": "Profile Image Url" + } + }, + "type": "object", + "required": [ + "id", + "name", + "email", + "role", + "profile_image_url" + ], + "title": "UserResponse" + }, + "open_webui__routers__chats__MessageForm": { + "properties": { + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "MessageForm" + }, + "open_webui__routers__evaluations__UserResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "email": { + "type": "string", + "title": "Email" + }, + "role": { + "type": "string", + "title": "Role", + "default": "pending" + }, + "last_active_at": { + "type": "integer", + "title": "Last Active At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "created_at": { + "type": "integer", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "email", + "last_active_at", + "updated_at", + "created_at" + ], + "title": "UserResponse" + }, + "open_webui__routers__images__ConfigForm": { + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "engine": { + "type": "string", + "title": "Engine" + }, + "prompt_generation": { + "type": "boolean", + "title": "Prompt Generation" + }, + "openai": { + "$ref": "#/components/schemas/open_webui__routers__images__OpenAIConfigForm" + }, + "automatic1111": { + "$ref": "#/components/schemas/Automatic1111ConfigForm" + }, + "comfyui": { + "$ref": "#/components/schemas/ComfyUIConfigForm" + }, + "gemini": { + "$ref": "#/components/schemas/GeminiConfigForm" + } + }, + "type": "object", + "required": [ + "enabled", + "engine", + "prompt_generation", + "openai", + "automatic1111", + "comfyui", + "gemini" + ], + "title": "ConfigForm" + }, + "open_webui__routers__images__OpenAIConfigForm": { + "properties": { + "OPENAI_API_BASE_URL": { + "type": "string", + "title": "Openai Api Base Url" + }, + "OPENAI_API_KEY": { + "type": "string", + "title": "Openai Api Key" + } + }, + "type": "object", + "required": [ + "OPENAI_API_BASE_URL", + "OPENAI_API_KEY" + ], + "title": "OpenAIConfigForm" + }, + "open_webui__routers__ollama__ConnectionVerificationForm": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Key" + } + }, + "type": "object", + "required": [ + "url" + ], + "title": "ConnectionVerificationForm" + }, + "open_webui__routers__ollama__OllamaConfigForm": { + "properties": { + "ENABLE_OLLAMA_API": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Ollama Api" + }, + "OLLAMA_BASE_URLS": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ollama Base Urls" + }, + "OLLAMA_API_CONFIGS": { + "additionalProperties": true, + "type": "object", + "title": "Ollama Api Configs" + } + }, + "type": "object", + "required": [ + "OLLAMA_BASE_URLS", + "OLLAMA_API_CONFIGS" + ], + "title": "OllamaConfigForm" + }, + "open_webui__routers__openai__ConnectionVerificationForm": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "key": { + "type": "string", + "title": "Key" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Config" + } + }, + "type": "object", + "required": [ + "url", + "key" + ], + "title": "ConnectionVerificationForm" + }, + "open_webui__routers__openai__OpenAIConfigForm": { + "properties": { + "ENABLE_OPENAI_API": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Openai Api" + }, + "OPENAI_API_BASE_URLS": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Openai Api Base Urls" + }, + "OPENAI_API_KEYS": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Openai Api Keys" + }, + "OPENAI_API_CONFIGS": { + "additionalProperties": true, + "type": "object", + "title": "Openai Api Configs" + } + }, + "type": "object", + "required": [ + "OPENAI_API_BASE_URLS", + "OPENAI_API_KEYS", + "OPENAI_API_CONFIGS" + ], + "title": "OpenAIConfigForm" + }, + "open_webui__routers__retrieval__ConfigForm": { + "properties": { + "RAG_TEMPLATE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rag Template" + }, + "TOP_K": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Top K" + }, + "BYPASS_EMBEDDING_AND_RETRIEVAL": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Bypass Embedding And Retrieval" + }, + "RAG_FULL_CONTEXT": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Rag Full Context" + }, + "ENABLE_RAG_HYBRID_SEARCH": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Rag Hybrid Search" + }, + "TOP_K_RERANKER": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Top K Reranker" + }, + "RELEVANCE_THRESHOLD": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Relevance Threshold" + }, + "HYBRID_BM25_WEIGHT": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Hybrid Bm25 Weight" + }, + "CONTENT_EXTRACTION_ENGINE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content Extraction Engine" + }, + "PDF_EXTRACT_IMAGES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Pdf Extract Images" + }, + "DATALAB_MARKER_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Datalab Marker Api Key" + }, + "DATALAB_MARKER_LANGS": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Datalab Marker Langs" + }, + "DATALAB_MARKER_SKIP_CACHE": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Datalab Marker Skip Cache" + }, + "DATALAB_MARKER_FORCE_OCR": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Datalab Marker Force Ocr" + }, + "DATALAB_MARKER_PAGINATE": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Datalab Marker Paginate" + }, + "DATALAB_MARKER_STRIP_EXISTING_OCR": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Datalab Marker Strip Existing Ocr" + }, + "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Datalab Marker Disable Image Extraction" + }, + "DATALAB_MARKER_USE_LLM": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Datalab Marker Use Llm" + }, + "DATALAB_MARKER_OUTPUT_FORMAT": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Datalab Marker Output Format" + }, + "EXTERNAL_DOCUMENT_LOADER_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Document Loader Url" + }, + "EXTERNAL_DOCUMENT_LOADER_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Document Loader Api Key" + }, + "TIKA_SERVER_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tika Server Url" + }, + "DOCLING_SERVER_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Docling Server Url" + }, + "DOCLING_OCR_ENGINE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Docling Ocr Engine" + }, + "DOCLING_OCR_LANG": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Docling Ocr Lang" + }, + "DOCLING_DO_PICTURE_DESCRIPTION": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Docling Do Picture Description" + }, + "DOCLING_PICTURE_DESCRIPTION_MODE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Docling Picture Description Mode" + }, + "DOCLING_PICTURE_DESCRIPTION_LOCAL": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Docling Picture Description Local" + }, + "DOCLING_PICTURE_DESCRIPTION_API": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Docling Picture Description Api" + }, + "DOCUMENT_INTELLIGENCE_ENDPOINT": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Document Intelligence Endpoint" + }, + "DOCUMENT_INTELLIGENCE_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Document Intelligence Key" + }, + "MISTRAL_OCR_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mistral Ocr Api Key" + }, + "RAG_RERANKING_MODEL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rag Reranking Model" + }, + "RAG_RERANKING_ENGINE": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rag Reranking Engine" + }, + "RAG_EXTERNAL_RERANKER_URL": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rag External Reranker Url" + }, + "RAG_EXTERNAL_RERANKER_API_KEY": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rag External Reranker Api Key" + }, + "TEXT_SPLITTER": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Text Splitter" + }, + "CHUNK_SIZE": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Chunk Size" + }, + "CHUNK_OVERLAP": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Chunk Overlap" + }, + "FILE_MAX_SIZE": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "File Max Size" + }, + "FILE_MAX_COUNT": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "File Max Count" + }, + "FILE_IMAGE_COMPRESSION_WIDTH": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "File Image Compression Width" + }, + "FILE_IMAGE_COMPRESSION_HEIGHT": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "File Image Compression Height" + }, + "ALLOWED_FILE_EXTENSIONS": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Allowed File Extensions" + }, + "ENABLE_GOOGLE_DRIVE_INTEGRATION": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Google Drive Integration" + }, + "ENABLE_ONEDRIVE_INTEGRATION": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enable Onedrive Integration" + }, + "web": { + "anyOf": [ + { + "$ref": "#/components/schemas/WebConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "title": "ConfigForm" + }, + "open_webui__routers__retrieval__OllamaConfigForm": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "key": { + "type": "string", + "title": "Key" + } + }, + "type": "object", + "required": [ + "url", + "key" + ], + "title": "OllamaConfigForm" + }, + "open_webui__routers__retrieval__OpenAIConfigForm": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "key": { + "type": "string", + "title": "Key" + } + }, + "type": "object", + "required": [ + "url", + "key" + ], + "title": "OpenAIConfigForm" + }, + "open_webui__routers__users__UserResponse": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "profile_image_url": { + "type": "string", + "title": "Profile Image Url" + }, + "active": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Active" + } + }, + "type": "object", + "required": [ + "name", + "profile_image_url" + ], + "title": "UserResponse" + } + }, + "securitySchemes": { + "HTTPBearer": { + "type": "http", + "scheme": "bearer" + } + } + } +} \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..90c9f1a --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,35 @@ +Conduit is an open-source, native mobile client for Open‑WebUI. Connect to your own server to chat with AI models, manage conversations, and take your self‑hosted AI with you—securely and on the go. + +Features +- Real-time streaming chat +- Model selection +- Conversation search and management +- Voice input (speech-to-text) +- File and image uploads for retrieval-augmented generation (RAG) +- Vision and multi‑modal support +- Markdown rendering with syntax highlighting +- Light, dark, and system themes +- Secure credential storage (Keychain/Keystore) +- Offline-aware experience + +Requirements +- Requires an existing Open‑WebUI server. Conduit does not host or provide AI models. +- No data is sent to third-party services by default; everything stays with your configured server. + +Permissions +- Microphone: Voice input +- Camera and Photos/Storage: Image/file attachments +- Network: Connect to your Open‑WebUI server + +Support & Source Code: + +LuCI Mobile is an open-source project. For support, to report issues, or to view the source code, please visit our GitHub repository: + +https://github.com/cogwheel0/conduit + +Email: cogwheel@cogwheel.app + +----- + +Disclaimer: This is an independent, third-party application licensed under the GNU General Public License v3.0 (GPLv3) and is not officially affiliated with the Open WebUI project. + diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..3f1eeb9 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_01.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_01.png new file mode 100644 index 0000000..b3ed8b3 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_01.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_02.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_02.png new file mode 100644 index 0000000..0a99fc1 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_02.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_03.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_03.png new file mode 100644 index 0000000..4e9593d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_03.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_04.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_04.png new file mode 100644 index 0000000..a03cdd8 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_04.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_05.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_05.png new file mode 100644 index 0000000..1a3cd42 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_05.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_06.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_06.png new file mode 100644 index 0000000..3e6a21c Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_06.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_07.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_07.png new file mode 100644 index 0000000..e39477e Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_07.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..f2f30ef --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1,2 @@ +Native mobile client for Open‑WebUI. Chat with your self‑hosted AI. + diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..a454a94 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1,2 @@ +Conduit + diff --git a/fastlane/metadata/en-US/description.txt b/fastlane/metadata/en-US/description.txt new file mode 100644 index 0000000..f8ae6dc --- /dev/null +++ b/fastlane/metadata/en-US/description.txt @@ -0,0 +1,24 @@ +Conduit is an open-source, native mobile client for Open‑WebUI. Connect to your own server to chat with AI models, manage conversations, and take your self‑hosted AI with you—securely and on the go. + +Key Features +- Real-time streaming chat +- Model selection +- Conversation search and management +- Voice input (speech-to-text) +- File and image uploads for retrieval-augmented generation (RAG) +- Vision and multi‑modal support +- Markdown rendering with syntax highlighting +- Light, dark, and system themes +- Secure credential storage + +Requirements +- An existing Open‑WebUI server (Conduit does not host or provide AI models). + +Privacy & Permissions +- Microphone: For voice input +- Camera and Photos: For image/file attachments +- Network: To connect to your Open‑WebUI server + +Open Source +Built with Flutter and maintained by the community. Contributions are welcome. + diff --git a/fastlane/metadata/en-US/images/icon.png b/fastlane/metadata/en-US/images/icon.png new file mode 100644 index 0000000..72600be Binary files /dev/null and b/fastlane/metadata/en-US/images/icon.png differ diff --git a/fastlane/metadata/en-US/images/phoneScreenshots/flutter_01.png b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_01.png new file mode 100644 index 0000000..82baa03 Binary files /dev/null and b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_01.png differ diff --git a/fastlane/metadata/en-US/images/phoneScreenshots/flutter_02.png b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_02.png new file mode 100644 index 0000000..1bf2ec7 Binary files /dev/null and b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_02.png differ diff --git a/fastlane/metadata/en-US/images/phoneScreenshots/flutter_03.png b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_03.png new file mode 100644 index 0000000..d933a39 Binary files /dev/null and b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_03.png differ diff --git a/fastlane/metadata/en-US/images/phoneScreenshots/flutter_04.png b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_04.png new file mode 100644 index 0000000..497eae2 Binary files /dev/null and b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_04.png differ diff --git a/fastlane/metadata/en-US/images/phoneScreenshots/flutter_05.png b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_05.png new file mode 100644 index 0000000..ccac460 Binary files /dev/null and b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_05.png differ diff --git a/fastlane/metadata/en-US/images/phoneScreenshots/flutter_06.png b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_06.png new file mode 100644 index 0000000..c3a4675 Binary files /dev/null and b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_06.png differ diff --git a/fastlane/metadata/en-US/images/phoneScreenshots/flutter_07.png b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_07.png new file mode 100644 index 0000000..56a9ad9 Binary files /dev/null and b/fastlane/metadata/en-US/images/phoneScreenshots/flutter_07.png differ diff --git a/fastlane/metadata/en-US/name.txt b/fastlane/metadata/en-US/name.txt new file mode 100644 index 0000000..a454a94 --- /dev/null +++ b/fastlane/metadata/en-US/name.txt @@ -0,0 +1,2 @@ +Conduit + diff --git a/fastlane/metadata/en-US/subtitle.txt b/fastlane/metadata/en-US/subtitle.txt new file mode 100644 index 0000000..f8cd883 --- /dev/null +++ b/fastlane/metadata/en-US/subtitle.txt @@ -0,0 +1,2 @@ +Chat with your self‑hosted AI + diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6c8fdc7 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png new file mode 100644 index 0000000..c91bdc8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png new file mode 100644 index 0000000..c91bdc8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png new file mode 100644 index 0000000..fac8f98 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png new file mode 100644 index 0000000..bdab698 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png new file mode 100644 index 0000000..129a717 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png new file mode 100644 index 0000000..cdb7f2f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png new file mode 100644 index 0000000..cdb7f2f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png new file mode 100644 index 0000000..f8c4ac4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png new file mode 100644 index 0000000..129a717 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png new file mode 100644 index 0000000..efcb9c5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png new file mode 100644 index 0000000..efcb9c5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png new file mode 100644 index 0000000..6f93a9b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png new file mode 100644 index 0000000..c91bdc8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png new file mode 100644 index 0000000..6f93a9b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png new file mode 100644 index 0000000..33d73cb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png new file mode 100644 index 0000000..f8ff3ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png new file mode 100644 index 0000000..6f93a9b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png new file mode 100644 index 0000000..a2140ff Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png new file mode 100644 index 0000000..33d73cb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png new file mode 100644 index 0000000..406302f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png new file mode 100644 index 0000000..a5aa2d7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..bd04914 --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,134 @@ +{ + "images": [ + { + "filename": "AppIcon@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "AppIcon@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "AppIcon~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" + }, + { + "filename": "AppIcon@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" + }, + { + "filename": "AppIcon-83.5@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" + }, + { + "filename": "AppIcon-40@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "AppIcon-40@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" + }, + { + "filename": "AppIcon-40~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" + }, + { + "filename": "AppIcon-40@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "AppIcon-20@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "AppIcon-20@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" + }, + { + "filename": "AppIcon-20~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" + }, + { + "filename": "AppIcon-20@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "AppIcon-29.png", + "idiom": "iphone", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "AppIcon-29@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-29@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "AppIcon-29~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "AppIcon-29@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-60@2x~car.png", + "idiom": "car", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "AppIcon-60@3x~car.png", + "idiom": "car", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "AppIcon~ios-marketing.png", + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" + } + ], + "info": { + "author": "iconkitchen", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json new file mode 100644 index 0000000..9f447e1 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png new file mode 100644 index 0000000..e815fd6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..00cabce --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "LaunchImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "LaunchImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..a1f6a19 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..578245c Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..f32c57b Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..7aa6dfb --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..c5e4936 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,68 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Conduit + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + conduit + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + + NSMicrophoneUsageDescription + Conduit needs access to your microphone for voice input + NSCameraUsageDescription + Conduit needs access to your camera to take photos for chat + NSPhotoLibraryUsageDescription + Conduit needs access to your photo library to select images for chat + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + UIStatusBarHidden + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/core/auth/api_auth_interceptor.dart b/lib/core/auth/api_auth_interceptor.dart new file mode 100644 index 0000000..6765eb1 --- /dev/null +++ b/lib/core/auth/api_auth_interceptor.dart @@ -0,0 +1,146 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// Consistent authentication interceptor for all API requests +/// Implements security requirements from OpenAPI specification +class ApiAuthInterceptor extends Interceptor { + String? _authToken; + + // Callbacks for auth events + void Function()? onAuthTokenInvalid; + Future Function()? onTokenInvalidated; + + // Public endpoints that don't require authentication + static const Set _publicEndpoints = { + '/health', + '/api/v1/auths/signin', + '/api/v1/auths/signup', + '/api/v1/auths/signup/enabled', + '/api/v1/auths/trusted-header-auth', + '/ollama/api/ps', + '/ollama/api/version', + '/docs', + '/openapi.json', + '/swagger', + '/api/docs', + }; + + // Endpoints that have optional authentication (work without but better with) + static const Set _optionalAuthEndpoints = { + '/api/models', + '/api/v1/configs/models', + }; + + ApiAuthInterceptor({ + String? authToken, + this.onAuthTokenInvalid, + this.onTokenInvalidated, + }) : _authToken = authToken; + + void updateAuthToken(String? token) { + _authToken = token; + } + + String? get authToken => _authToken; + + /// Check if endpoint requires authentication based on OpenAPI spec + bool _requiresAuth(String path) { + // Direct public endpoint match + if (_publicEndpoints.contains(path)) { + return false; + } + + // Check for partial matches (e.g., /ollama/* endpoints) + for (final publicPattern in _publicEndpoints) { + if (publicPattern.endsWith('*') && + path.startsWith( + publicPattern.substring(0, publicPattern.length - 1), + )) { + return false; + } + } + + // All other endpoints require authentication per OpenAPI spec + return true; + } + + /// Check if endpoint is better with auth but works without + bool _hasOptionalAuth(String path) { + return _optionalAuthEndpoints.contains(path); + } + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final path = options.path; + final requiresAuth = _requiresAuth(path); + final hasOptionalAuth = _hasOptionalAuth(path); + + debugPrint( + 'DEBUG: Auth interceptor for $path - requires: $requiresAuth, optional: $hasOptionalAuth, token present: ${_authToken != null}', + ); + + if (requiresAuth) { + // Strictly required authentication + if (_authToken == null || _authToken!.isEmpty) { + final error = DioException( + requestOptions: options, + response: Response( + requestOptions: options, + statusCode: 401, + data: {'detail': 'Authentication required for this endpoint'}, + ), + type: DioExceptionType.badResponse, + ); + handler.reject(error); + return; + } + options.headers['Authorization'] = 'Bearer $_authToken'; + } else if (hasOptionalAuth && + _authToken != null && + _authToken!.isNotEmpty) { + // Optional authentication - add if available + options.headers['Authorization'] = 'Bearer $_authToken'; + } + + // Add other common headers for API consistency + options.headers['Content-Type'] ??= 'application/json'; + options.headers['Accept'] ??= 'application/json'; + + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final statusCode = err.response?.statusCode; + final path = err.requestOptions.path; + + // Handle authentication errors consistently + if (statusCode == 401) { + // 401 always indicates invalid/expired auth token + debugPrint('DEBUG: 401 Unauthorized on $path - clearing auth token'); + _clearAuthToken(); + } else if (statusCode == 403) { + // 403 on protected endpoints indicates insufficient permissions or invalid token + final requiresAuth = _requiresAuth(path); + final optionalAuth = _hasOptionalAuth(path); + if (requiresAuth && !optionalAuth) { + debugPrint( + 'DEBUG: 403 Forbidden on protected endpoint $path - clearing auth token', + ); + _clearAuthToken(); + } else { + debugPrint( + 'DEBUG: 403 Forbidden on public/optional endpoint $path - keeping auth token', + ); + } + } + + handler.next(err); + } + + void _clearAuthToken() { + _authToken = null; + onAuthTokenInvalid?.call(); + onTokenInvalidated?.call(); + } +} diff --git a/lib/core/auth/auth_cache_manager.dart b/lib/core/auth/auth_cache_manager.dart new file mode 100644 index 0000000..2eaa324 --- /dev/null +++ b/lib/core/auth/auth_cache_manager.dart @@ -0,0 +1,194 @@ +import 'package:flutter/foundation.dart'; +import 'auth_state_manager.dart'; + +/// Comprehensive caching manager for auth-related operations +/// Reduces redundant operations and improves app performance +class AuthCacheManager { + static final AuthCacheManager _instance = AuthCacheManager._internal(); + factory AuthCacheManager() => _instance; + AuthCacheManager._internal(); + + // Cache for various auth-related operations + final Map _cache = {}; + final Map _cacheTimestamps = {}; + + // Cache timeouts for different types of data + static const Duration _shortCache = Duration( + minutes: 2, + ); // For frequently changing data + static const Duration _mediumCache = Duration( + minutes: 5, + ); // For moderately stable data + static const Duration _longCache = Duration(minutes: 15); // For stable data + + // Cache keys + static const String _userDataKey = 'user_data'; + static const String _serverConnectionKey = 'server_connection'; + static const String _credentialsExistKey = 'credentials_exist'; + static const String _serverConfigsKey = 'server_configs'; + + /// Cache user data with medium timeout + void cacheUserData(dynamic userData) { + _cache[_userDataKey] = userData; + _cacheTimestamps[_userDataKey] = DateTime.now(); + debugPrint('DEBUG: User data cached'); + } + + /// Get cached user data + dynamic getCachedUserData() { + if (_isCacheValid(_userDataKey, _mediumCache)) { + debugPrint('DEBUG: Using cached user data'); + return _cache[_userDataKey]; + } + return null; + } + + /// Cache server connection status with short timeout + void cacheServerConnection(bool isConnected) { + _cache[_serverConnectionKey] = isConnected; + _cacheTimestamps[_serverConnectionKey] = DateTime.now(); + } + + /// Get cached server connection status + bool? getCachedServerConnection() { + if (_isCacheValid(_serverConnectionKey, _shortCache)) { + return _cache[_serverConnectionKey] as bool?; + } + return null; + } + + /// Cache credentials existence with medium timeout + void cacheCredentialsExist(bool exist) { + _cache[_credentialsExistKey] = exist; + _cacheTimestamps[_credentialsExistKey] = DateTime.now(); + } + + /// Get cached credentials existence + bool? getCachedCredentialsExist() { + if (_isCacheValid(_credentialsExistKey, _mediumCache)) { + return _cache[_credentialsExistKey] as bool?; + } + return null; + } + + /// Cache server configurations with long timeout + void cacheServerConfigs(List configs) { + _cache[_serverConfigsKey] = configs; + _cacheTimestamps[_serverConfigsKey] = DateTime.now(); + } + + /// Get cached server configurations + List? getCachedServerConfigs() { + if (_isCacheValid(_serverConfigsKey, _longCache)) { + return _cache[_serverConfigsKey] as List?; + } + return null; + } + + /// Check if cache entry is valid + bool _isCacheValid(String key, Duration timeout) { + final timestamp = _cacheTimestamps[key]; + if (timestamp == null) return false; + + return DateTime.now().difference(timestamp) < timeout; + } + + /// Clear specific cache entry + void clearCacheEntry(String key) { + _cache.remove(key); + _cacheTimestamps.remove(key); + debugPrint('DEBUG: Cache entry cleared: $key'); + } + + /// Clear all auth-related cache + void clearAuthCache() { + _cache.clear(); + _cacheTimestamps.clear(); + debugPrint('DEBUG: All auth cache cleared'); + } + + /// Clear expired cache entries + void cleanExpiredCache() { + final now = DateTime.now(); + final expiredKeys = []; + + for (final entry in _cacheTimestamps.entries) { + // Use the longest timeout for cleanup to be conservative + if (now.difference(entry.value) > _longCache) { + expiredKeys.add(entry.key); + } + } + + for (final key in expiredKeys) { + _cache.remove(key); + _cacheTimestamps.remove(key); + } + + if (expiredKeys.isNotEmpty) { + debugPrint('DEBUG: Cleaned ${expiredKeys.length} expired cache entries'); + } + } + + /// Get cache statistics for monitoring + Map getCacheStats() { + final now = DateTime.now(); + final stats = {}; + + stats['totalEntries'] = _cache.length; + stats['entries'] = >{}; + + for (final key in _cache.keys) { + final timestamp = _cacheTimestamps[key]; + if (timestamp != null) { + stats['entries'][key] = { + 'age': now.difference(timestamp).inSeconds, + 'hasData': _cache[key] != null, + }; + } + } + + return stats; + } + + /// Optimize cache by removing least recently used entries if cache gets too large + void optimizeCache() { + const maxCacheSize = 20; // Reasonable limit for auth cache + + if (_cache.length <= maxCacheSize) return; + + // Sort by timestamp (oldest first) + final sortedEntries = _cacheTimestamps.entries.toList() + ..sort((a, b) => a.value.compareTo(b.value)); + + // Remove oldest entries + final entriesToRemove = sortedEntries.length - maxCacheSize; + for (int i = 0; i < entriesToRemove; i++) { + final key = sortedEntries[i].key; + _cache.remove(key); + _cacheTimestamps.remove(key); + } + + debugPrint('DEBUG: Cache optimized, removed $entriesToRemove old entries'); + } + + /// Cache state from AuthState for quick access + void cacheAuthState(AuthState authState) { + if (authState.user != null) { + cacheUserData(authState.user); + } + + // Don't cache loading or error states + if (authState.status == AuthStatus.authenticated) { + _cache['auth_status'] = authState.status; + _cacheTimestamps['auth_status'] = DateTime.now(); + } + } + + /// Get cached auth status + AuthStatus? getCachedAuthStatus() { + if (_isCacheValid('auth_status', _shortCache)) { + return _cache['auth_status'] as AuthStatus?; + } + return null; + } +} diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart new file mode 100644 index 0000000..03be868 --- /dev/null +++ b/lib/core/auth/auth_state_manager.dart @@ -0,0 +1,562 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +// Types are used through app_providers.dart +import '../providers/app_providers.dart'; +import '../models/user.dart'; +import 'token_validator.dart'; +import 'auth_cache_manager.dart'; + +/// Comprehensive auth state representation +@immutable +class AuthState { + const AuthState({ + required this.status, + this.token, + this.user, + this.error, + this.isLoading = false, + }); + + final AuthStatus status; + final String? token; + final dynamic user; // Replace with proper User type + final String? error; + final bool isLoading; + + bool get isAuthenticated => + status == AuthStatus.authenticated && token != null; + bool get hasValidToken => token != null && token!.isNotEmpty; + bool get needsLogin => + status == AuthStatus.unauthenticated || status == AuthStatus.tokenExpired; + + AuthState copyWith({ + AuthStatus? status, + String? token, + dynamic user, + String? error, + bool? isLoading, + bool clearToken = false, + bool clearUser = false, + bool clearError = false, + }) { + return AuthState( + status: status ?? this.status, + token: clearToken ? null : (token ?? this.token), + user: clearUser ? null : (user ?? this.user), + error: clearError ? null : (error ?? this.error), + isLoading: isLoading ?? this.isLoading, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AuthState && + other.status == status && + other.token == token && + other.user == user && + other.error == error && + other.isLoading == isLoading; + } + + @override + int get hashCode => Object.hash(status, token, user, error, isLoading); + + @override + String toString() => + 'AuthState(status: $status, hasToken: ${token != null}, hasUser: ${user != null}, error: $error, isLoading: $isLoading)'; +} + +enum AuthStatus { + initial, + loading, + authenticated, + unauthenticated, + tokenExpired, + error, +} + +/// Unified auth state manager - single source of truth for all auth operations +class AuthStateManager extends StateNotifier { + AuthStateManager(this._ref) + : super(const AuthState(status: AuthStatus.initial)) { + _initialize(); + } + + final Ref _ref; + final AuthCacheManager _cacheManager = AuthCacheManager(); + + /// Initialize auth state from storage + Future _initialize() async { + state = state.copyWith(status: AuthStatus.loading, isLoading: true); + + try { + final storage = _ref.read(optimizedStorageServiceProvider); + final token = await storage.getAuthToken(); + + if (token != null && token.isNotEmpty) { + // Validate token before setting authenticated state + final isValid = await _validateToken(token); + if (isValid) { + state = state.copyWith( + status: AuthStatus.authenticated, + token: token, + isLoading: false, + clearError: true, + ); + + // Update API service with token + _updateApiServiceToken(token); + + // Load user data in background + _loadUserData(); + } else { + // Token is invalid, clear it + await storage.deleteAuthToken(); + state = state.copyWith( + status: AuthStatus.unauthenticated, + isLoading: false, + clearToken: true, + clearError: true, + ); + } + } else { + state = state.copyWith( + status: AuthStatus.unauthenticated, + isLoading: false, + clearToken: true, + clearError: true, + ); + } + } catch (e) { + debugPrint('ERROR: Auth initialization failed: $e'); + state = state.copyWith( + status: AuthStatus.error, + error: 'Failed to initialize auth: $e', + isLoading: false, + ); + } + } + + /// Perform login with credentials + Future login( + String username, + String password, { + bool rememberCredentials = false, + }) async { + state = state.copyWith( + status: AuthStatus.loading, + isLoading: true, + clearError: true, + ); + + try { + // Ensure API service is available (active server/provider rebuild race) + await _ensureApiServiceAvailable(); + final api = _ref.read(apiServiceProvider); + if (api == null) { + throw Exception('No server connection available'); + } + + // Perform login API call + final response = await api.login(username, password); + + // Extract and validate token + final token = response['token'] ?? response['access_token']; + if (token == null || token.toString().trim().isEmpty) { + throw Exception('No authentication token received'); + } + + final tokenStr = token.toString(); + if (!_isValidTokenFormat(tokenStr)) { + throw Exception('Invalid authentication token format'); + } + + // Save token to storage + final storage = _ref.read(optimizedStorageServiceProvider); + await storage.saveAuthToken(tokenStr); + + // Save credentials if requested + if (rememberCredentials) { + final activeServer = await _ref.read(activeServerProvider.future); + if (activeServer != null) { + await storage.saveCredentials( + serverId: activeServer.id, + username: username, + password: password, + ); + await storage.setRememberCredentials(true); + } + } + + // Update state and API service + state = state.copyWith( + status: AuthStatus.authenticated, + token: tokenStr, + isLoading: false, + clearError: true, + ); + + _updateApiServiceToken(tokenStr); + + // Cache the successful auth state + _cacheManager.cacheAuthState(state); + + // Load user data in background + _loadUserData(); + + debugPrint('DEBUG: Login successful'); + return true; + } catch (e) { + debugPrint('ERROR: Login failed: $e'); + state = state.copyWith( + status: AuthStatus.error, + error: e.toString(), + isLoading: false, + clearToken: true, + ); + return false; + } + } + + /// Wait briefly until the API service becomes available + Future _ensureApiServiceAvailable({ + Duration timeout = const Duration(seconds: 2), + }) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + final api = _ref.read(apiServiceProvider); + if (api != null) return; + await Future.delayed(const Duration(milliseconds: 50)); + } + } + + /// Perform silent auto-login with saved credentials + Future silentLogin() async { + state = state.copyWith( + status: AuthStatus.loading, + isLoading: true, + clearError: true, + ); + + try { + final storage = _ref.read(optimizedStorageServiceProvider); + final savedCredentials = await storage.getSavedCredentials(); + + if (savedCredentials == null) { + state = state.copyWith( + status: AuthStatus.unauthenticated, + isLoading: false, + clearError: true, + ); + return false; + } + + final serverId = savedCredentials['serverId']!; + final username = savedCredentials['username']!; + final password = savedCredentials['password']!; + + // Set active server if needed + await storage.setActiveServerId(serverId); + _ref.invalidate(activeServerProvider); + + // Wait for server connection + final activeServer = await _ref.read(activeServerProvider.future); + if (activeServer == null) { + await storage.setActiveServerId(null); + state = state.copyWith( + status: AuthStatus.error, + error: 'Server configuration not found', + isLoading: false, + ); + return false; + } + + // Attempt login + return await login(username, password, rememberCredentials: false); + } catch (e) { + debugPrint('ERROR: Silent login failed: $e'); + + // Clear invalid credentials on auth errors + if (e.toString().contains('401') || + e.toString().contains('403') || + e.toString().contains('authentication') || + e.toString().contains('unauthorized')) { + final storage = _ref.read(optimizedStorageServiceProvider); + await storage.deleteSavedCredentials(); + } + + state = state.copyWith( + status: AuthStatus.unauthenticated, + error: e.toString(), + isLoading: false, + clearToken: true, + ); + return false; + } + } + + /// Handle token invalidation (called by API service) + Future onTokenInvalidated() async { + debugPrint('DEBUG: Auth token invalidated'); + + // Clear token from storage + final storage = _ref.read(optimizedStorageServiceProvider); + await storage.deleteAuthToken(); + + // Update state + state = state.copyWith( + status: AuthStatus.tokenExpired, + clearToken: true, + clearUser: true, + clearError: true, + ); + + // Attempt silent re-login if credentials are available + final hasCredentials = await storage.getSavedCredentials() != null; + if (hasCredentials) { + debugPrint('DEBUG: Attempting silent re-login after token invalidation'); + await silentLogin(); + } + } + + /// Logout user + Future logout() async { + state = state.copyWith(status: AuthStatus.loading, isLoading: true); + + try { + // Call server logout if possible + final api = _ref.read(apiServiceProvider); + if (api != null) { + try { + await api.logout(); + } catch (e) { + debugPrint('Warning: Server logout failed: $e'); + } + } + + // Clear all local auth data + final storage = _ref.read(optimizedStorageServiceProvider); + await storage.clearAuthData(); + + // Update state + state = state.copyWith( + status: AuthStatus.unauthenticated, + isLoading: false, + clearToken: true, + clearUser: true, + clearError: true, + ); + + debugPrint('DEBUG: Logout complete'); + } catch (e) { + debugPrint('ERROR: Logout failed: $e'); + // Even if logout fails, clear local state + state = state.copyWith( + status: AuthStatus.unauthenticated, + isLoading: false, + clearToken: true, + clearUser: true, + error: 'Logout error: $e', + ); + } + } + + /// Load user data in background with JWT extraction fallback + Future _loadUserData() async { + try { + // First try to extract user info from JWT token if available + if (state.token != null) { + final jwtUserInfo = TokenValidator.extractUserInfo(state.token!); + if (jwtUserInfo != null) { + debugPrint('DEBUG: Extracted user info from JWT token'); + state = state.copyWith(user: jwtUserInfo); + + // Still try to load from server in background for complete data + Future.microtask(() => _loadServerUserData()); + return; + } + } + + // Fall back to server data loading + await _loadServerUserData(); + } catch (e) { + debugPrint('Warning: Failed to load user data: $e'); + // Don't update state on user data load failure + } + } + + /// Load complete user data from server + Future _loadServerUserData() async { + try { + final api = _ref.read(apiServiceProvider); + if (api != null && state.isAuthenticated) { + // Check if we already have user data from token validation + if (state.user != null) { + debugPrint( + 'DEBUG: User data already available from token validation', + ); + return; + } + + final user = await api.getCurrentUser(); + state = state.copyWith(user: user); + debugPrint('DEBUG: Loaded complete user data from server'); + } + } catch (e) { + debugPrint('Warning: Failed to load server user data: $e'); + // Don't update state on server data load failure - keep JWT data if available + } + } + + /// Update API service with current token + void _updateApiServiceToken(String token) { + final api = _ref.read(apiServiceProvider); + api?.updateAuthToken(token); + } + + /// Validate token format using advanced validation + bool _isValidTokenFormat(String token) { + final result = TokenValidator.validateTokenFormat(token); + return result.isValid; + } + + /// Validate token with comprehensive validation (format + server) + Future _validateToken(String token) async { + // Check cache first + final cachedResult = TokenValidationCache.getCachedResult(token); + if (cachedResult != null) { + debugPrint( + 'DEBUG: Using cached token validation result: ${cachedResult.isValid}', + ); + return cachedResult.isValid; + } + + // Fast format validation first + final formatResult = TokenValidator.validateTokenFormat(token); + if (!formatResult.isValid) { + debugPrint('DEBUG: Token format invalid: ${formatResult.message}'); + TokenValidationCache.cacheResult(token, formatResult); + return false; + } + + // If format is valid but token is expiring soon, try server validation + if (formatResult.isExpiringSoon) { + debugPrint('DEBUG: Token expiring soon, validating with server'); + } + + // Server validation (async with timeout) + try { + final api = _ref.read(apiServiceProvider); + if (api == null) { + debugPrint('DEBUG: No API service available for token validation'); + return formatResult.isValid; // Fall back to format validation + } + + User? validationUser; + final serverResult = await TokenValidator.validateTokenWithServer( + token, + () async { + // Update API with token for validation + api.updateAuthToken(token); + // Try to fetch user data as validation + validationUser = await api.getCurrentUser(); + return validationUser!; + }, + ); + + // Store the user data if validation was successful + if (serverResult.isValid && + validationUser != null && + state.isAuthenticated) { + state = state.copyWith(user: validationUser); + debugPrint('DEBUG: Cached user data from token validation'); + } + + TokenValidationCache.cacheResult(token, serverResult); + + debugPrint( + 'DEBUG: Server token validation: ${serverResult.isValid} - ${serverResult.message}', + ); + return serverResult.isValid; + } catch (e) { + debugPrint('DEBUG: Token server validation failed: $e'); + // On network error, fall back to format validation if it was valid + return formatResult.isValid; + } + } + + /// Check if user has saved credentials (with caching) + Future hasSavedCredentials() async { + // Check cache first + final cachedResult = _cacheManager.getCachedCredentialsExist(); + if (cachedResult != null) { + return cachedResult; + } + + try { + final storage = _ref.read(optimizedStorageServiceProvider); + final hasCredentials = await storage.hasCredentials(); + + // Cache the result + _cacheManager.cacheCredentialsExist(hasCredentials); + + return hasCredentials; + } catch (e) { + return false; + } + } + + /// Refresh current auth state + Future refresh() async { + // Clear cache before refresh to ensure fresh data + _cacheManager.clearAuthCache(); + TokenValidationCache.clearCache(); + + await _initialize(); + } + + /// Clean up expired caches (called periodically) + void cleanupCaches() { + _cacheManager.cleanExpiredCache(); + _cacheManager.optimizeCache(); + } + + /// Get performance statistics + Map getPerformanceStats() { + return { + 'authCache': _cacheManager.getCacheStats(), + 'tokenValidationCache': 'Managed by TokenValidationCache', + 'storageCache': 'Managed by OptimizedStorageService', + }; + } +} + +/// Provider for the unified auth state manager +final authStateManagerProvider = + StateNotifierProvider((ref) { + return AuthStateManager(ref); + }); + +/// Computed providers for common auth state queries +final isAuthenticatedProvider = Provider((ref) { + return ref.watch( + authStateManagerProvider.select((state) => state.isAuthenticated), + ); +}); + +final authTokenProvider2 = Provider((ref) { + return ref.watch(authStateManagerProvider.select((state) => state.token)); +}); + +final authUserProvider = Provider((ref) { + return ref.watch(authStateManagerProvider.select((state) => state.user)); +}); + +final authErrorProvider2 = Provider((ref) { + return ref.watch(authStateManagerProvider.select((state) => state.error)); +}); + +final isAuthLoadingProvider = Provider((ref) { + return ref.watch(authStateManagerProvider.select((state) => state.isLoading)); +}); diff --git a/lib/core/auth/token_validator.dart b/lib/core/auth/token_validator.dart new file mode 100644 index 0000000..e9ad101 --- /dev/null +++ b/lib/core/auth/token_validator.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:crypto/crypto.dart'; + +/// JWT token validation utilities +class TokenValidator { + static const Duration _validationTimeout = Duration(seconds: 5); + + /// Validate JWT token format and expiry without network call + static TokenValidationResult validateTokenFormat(String token) { + try { + // Basic format check + if (token.isEmpty || token.length < 10) { + return TokenValidationResult.invalid('Token too short'); + } + + // Check if it looks like a JWT (has at least 2 dots) + final parts = token.split('.'); + if (parts.length < 3) { + return TokenValidationResult.invalid('Invalid JWT format'); + } + + // Try to decode the payload to check expiry + try { + final payload = _decodeJWTPayload(parts[1]); + final exp = payload['exp'] as int?; + + if (exp != null) { + final expiryTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000); + final now = DateTime.now(); + + if (expiryTime.isBefore(now)) { + return TokenValidationResult.expired('Token expired'); + } + + // Check if token expires soon (within 5 minutes) + final fiveMinutesFromNow = now.add(const Duration(minutes: 5)); + if (expiryTime.isBefore(fiveMinutesFromNow)) { + return TokenValidationResult.expiringSoon( + 'Token expires soon', + expiryTime, + ); + } + } + + return TokenValidationResult.valid( + 'Token format valid', + expiryData: exp != null + ? DateTime.fromMillisecondsSinceEpoch(exp * 1000) + : null, + ); + } catch (e) { + // If we can't decode JWT, treat as opaque token + debugPrint( + 'DEBUG: Could not decode JWT payload, treating as opaque token: $e', + ); + return TokenValidationResult.valid('Opaque token format valid'); + } + } catch (e) { + return TokenValidationResult.invalid('Token validation error: $e'); + } + } + + /// Validate token with server (async with timeout) + static Future validateTokenWithServer( + String token, + Future Function() serverValidationCall, + ) async { + try { + // First check format + final formatResult = validateTokenFormat(token); + if (!formatResult.isValid) { + return formatResult; + } + + // If format is good, try server validation with timeout + final validationFuture = serverValidationCall(); + + final result = await validationFuture.timeout( + _validationTimeout, + onTimeout: () => throw Exception('Token validation timeout'), + ); + + return TokenValidationResult.valid( + 'Server validation successful', + serverData: result, + ); + } catch (e) { + if (e.toString().contains('timeout')) { + return TokenValidationResult.networkError( + 'Validation timeout - using cached result', + ); + } else if (e.toString().contains('401') || e.toString().contains('403')) { + return TokenValidationResult.invalid('Server rejected token'); + } else { + return TokenValidationResult.networkError( + 'Network error during validation: $e', + ); + } + } + } + + /// Decode JWT payload (without signature verification) + static Map _decodeJWTPayload(String base64Payload) { + // Add padding if needed + String padded = base64Payload; + while (padded.length % 4 != 0) { + padded += '='; + } + + // Decode base64 + final decoded = base64Url.decode(padded); + final jsonString = utf8.decode(decoded); + + return jsonDecode(jsonString) as Map; + } + + /// Extract user information from JWT token (if available) + static Map? extractUserInfo(String token) { + try { + final parts = token.split('.'); + if (parts.length < 3) return null; + + final payload = _decodeJWTPayload(parts[1]); + + // Extract common user fields + return { + 'sub': payload['sub'], // Subject (user ID) + 'username': + payload['username'] ?? + payload['name'] ?? + payload['preferred_username'], + 'email': payload['email'], + 'roles': payload['roles'] ?? payload['groups'], + 'exp': payload['exp'], + 'iat': payload['iat'], // Issued at + }; + } catch (e) { + debugPrint('DEBUG: Could not extract user info from token: $e'); + return null; + } + } + + /// Generate a cache key for token validation results + static String generateCacheKey(String token) { + final bytes = utf8.encode(token); + final digest = sha256.convert(bytes); + return digest.toString().substring( + 0, + 16, + ); // Use first 16 chars as cache key + } +} + +/// Result of token validation +class TokenValidationResult { + const TokenValidationResult._( + this.isValid, + this.status, + this.message, { + this.expiryData, + this.serverData, + }); + + const TokenValidationResult.valid( + String message, { + DateTime? expiryData, + dynamic serverData, + }) : this._( + true, + TokenValidationStatus.valid, + message, + expiryData: expiryData, + serverData: serverData, + ); + + const TokenValidationResult.invalid(String message) + : this._(false, TokenValidationStatus.invalid, message); + + const TokenValidationResult.expired(String message) + : this._(false, TokenValidationStatus.expired, message); + + const TokenValidationResult.expiringSoon(String message, DateTime expiryTime) + : this._( + true, + TokenValidationStatus.expiringSoon, + message, + expiryData: expiryTime, + ); + + const TokenValidationResult.networkError(String message) + : this._(false, TokenValidationStatus.networkError, message); + + final bool isValid; + final TokenValidationStatus status; + final String message; + final DateTime? expiryData; + final dynamic serverData; + + bool get isExpired => status == TokenValidationStatus.expired; + bool get isExpiringSoon => status == TokenValidationStatus.expiringSoon; + bool get hasNetworkError => status == TokenValidationStatus.networkError; + + @override + String toString() => + 'TokenValidationResult(isValid: $isValid, status: $status, message: $message)'; +} + +enum TokenValidationStatus { + valid, + invalid, + expired, + expiringSoon, + networkError, +} + +/// Cache for token validation results +class TokenValidationCache { + static final Map _cache = {}; + static const Duration _cacheTimeout = Duration(minutes: 5); + + static void cacheResult(String token, TokenValidationResult result) { + final key = TokenValidator.generateCacheKey(token); + _cache[key] = _CacheEntry(result, DateTime.now()); + + // Clean old entries + _cleanCache(); + } + + static TokenValidationResult? getCachedResult(String token) { + final key = TokenValidator.generateCacheKey(token); + final entry = _cache[key]; + + if (entry != null && + DateTime.now().difference(entry.timestamp) < _cacheTimeout) { + return entry.result; + } + + return null; + } + + static void clearCache() { + _cache.clear(); + } + + static void _cleanCache() { + final now = DateTime.now(); + _cache.removeWhere( + (key, entry) => now.difference(entry.timestamp) > _cacheTimeout, + ); + } +} + +class _CacheEntry { + const _CacheEntry(this.result, this.timestamp); + + final TokenValidationResult result; + final DateTime timestamp; +} diff --git a/lib/core/error/api_error.dart b/lib/core/error/api_error.dart new file mode 100644 index 0000000..4785dd5 --- /dev/null +++ b/lib/core/error/api_error.dart @@ -0,0 +1,397 @@ +import 'package:flutter/foundation.dart'; + +/// Standardized API error representation +/// Provides consistent error information across all API operations +@immutable +class ApiError implements Exception { + const ApiError._({ + required this.type, + required this.message, + this.endpoint, + this.method, + this.statusCode, + this.details, + this.fieldErrors = const {}, + this.originalError, + this.technical, + this.retryAfter, + this.timeoutDuration, + }); + + // Factory constructors for different error types + const ApiError.network({ + required String message, + String? endpoint, + String? method, + dynamic originalError, + String? technical, + }) : this._( + type: ApiErrorType.network, + message: message, + endpoint: endpoint, + method: method, + originalError: originalError, + technical: technical, + ); + + const ApiError.timeout({ + required String message, + String? endpoint, + String? method, + Duration? timeoutDuration, + }) : this._( + type: ApiErrorType.timeout, + message: message, + endpoint: endpoint, + method: method, + timeoutDuration: timeoutDuration, + ); + + const ApiError.authentication({ + required String message, + String? endpoint, + String? method, + int? statusCode, + }) : this._( + type: ApiErrorType.authentication, + message: message, + endpoint: endpoint, + method: method, + statusCode: statusCode, + ); + + const ApiError.authorization({ + required String message, + String? endpoint, + String? method, + int? statusCode, + }) : this._( + type: ApiErrorType.authorization, + message: message, + endpoint: endpoint, + method: method, + statusCode: statusCode, + ); + + const ApiError.validation({ + required String message, + String? endpoint, + String? method, + Map> fieldErrors = const {}, + ParsedErrorResponse? details, + }) : this._( + type: ApiErrorType.validation, + message: message, + endpoint: endpoint, + method: method, + statusCode: 422, + fieldErrors: fieldErrors, + details: details, + ); + + const ApiError.badRequest({ + required String message, + String? endpoint, + String? method, + ParsedErrorResponse? details, + }) : this._( + type: ApiErrorType.badRequest, + message: message, + endpoint: endpoint, + method: method, + statusCode: 400, + details: details, + ); + + const ApiError.notFound({ + required String message, + String? endpoint, + String? method, + int? statusCode, + }) : this._( + type: ApiErrorType.notFound, + message: message, + endpoint: endpoint, + method: method, + statusCode: statusCode ?? 404, + ); + + const ApiError.server({ + required String message, + String? endpoint, + String? method, + int? statusCode, + ParsedErrorResponse? details, + }) : this._( + type: ApiErrorType.server, + message: message, + endpoint: endpoint, + method: method, + statusCode: statusCode, + details: details, + ); + + const ApiError.rateLimit({ + required String message, + String? endpoint, + String? method, + int? statusCode, + Duration? retryAfter, + }) : this._( + type: ApiErrorType.rateLimit, + message: message, + endpoint: endpoint, + method: method, + statusCode: statusCode ?? 429, + retryAfter: retryAfter, + ); + + const ApiError.cancelled({ + required String message, + String? endpoint, + String? method, + }) : this._( + type: ApiErrorType.cancelled, + message: message, + endpoint: endpoint, + method: method, + ); + + const ApiError.security({ + required String message, + String? endpoint, + String? method, + }) : this._( + type: ApiErrorType.security, + message: message, + endpoint: endpoint, + method: method, + ); + + const ApiError.unknown({ + required String message, + String? endpoint, + String? method, + dynamic originalError, + String? technical, + }) : this._( + type: ApiErrorType.unknown, + message: message, + endpoint: endpoint, + method: method, + originalError: originalError, + technical: technical, + ); + + const ApiError.client({ + required String message, + String? endpoint, + String? method, + int? statusCode, + ParsedErrorResponse? details, + }) : this._( + type: ApiErrorType.badRequest, + message: message, + endpoint: endpoint, + method: method, + statusCode: statusCode, + details: details, + ); + + final ApiErrorType type; + final String message; + final String? endpoint; + final String? method; + final int? statusCode; + final ParsedErrorResponse? details; + final Map> fieldErrors; + final dynamic originalError; + final String? technical; + final Duration? retryAfter; + final Duration? timeoutDuration; + + /// Check if this error has field-specific validation errors + bool get hasFieldErrors => fieldErrors.isNotEmpty; + + /// Check if this error is retryable + bool get isRetryable { + switch (type) { + case ApiErrorType.network: + case ApiErrorType.timeout: + case ApiErrorType.server: + case ApiErrorType.rateLimit: + return true; + case ApiErrorType.authentication: + case ApiErrorType.authorization: + case ApiErrorType.validation: + case ApiErrorType.badRequest: + case ApiErrorType.notFound: + case ApiErrorType.cancelled: + case ApiErrorType.security: + case ApiErrorType.unknown: + return false; + } + } + + /// Get all field error messages as a flattened list + List get allFieldErrorMessages { + final messages = []; + for (final entry in fieldErrors.entries) { + final field = entry.key; + final errors = entry.value; + for (final error in errors) { + messages.add('$field: $error'); + } + } + return messages; + } + + /// Get first field error message for quick display + String? get firstFieldError { + if (fieldErrors.isEmpty) return null; + final firstEntry = fieldErrors.entries.first; + final field = firstEntry.key; + final firstError = firstEntry.value.first; + return '$field: $firstError'; + } + + /// Create a copy with updated fields + ApiError copyWith({ + ApiErrorType? type, + String? message, + String? endpoint, + String? method, + int? statusCode, + ParsedErrorResponse? details, + Map>? fieldErrors, + dynamic originalError, + String? technical, + Duration? retryAfter, + Duration? timeoutDuration, + }) { + return ApiError._( + type: type ?? this.type, + message: message ?? this.message, + endpoint: endpoint ?? this.endpoint, + method: method ?? this.method, + statusCode: statusCode ?? this.statusCode, + details: details ?? this.details, + fieldErrors: fieldErrors ?? this.fieldErrors, + originalError: originalError ?? this.originalError, + technical: technical ?? this.technical, + retryAfter: retryAfter ?? this.retryAfter, + timeoutDuration: timeoutDuration ?? this.timeoutDuration, + ); + } + + /// Convert to map for logging and debugging + Map toMap() { + return { + 'type': type.name, + 'message': message, + 'endpoint': endpoint, + 'method': method, + 'statusCode': statusCode, + 'fieldErrors': fieldErrors, + 'technical': technical, + 'retryAfter': retryAfter?.inSeconds, + 'timeoutDuration': timeoutDuration?.inSeconds, + 'isRetryable': isRetryable, + 'hasFieldErrors': hasFieldErrors, + }; + } + + @override + String toString() { + final buffer = StringBuffer(); + buffer.write('ApiError('); + buffer.write('type: ${type.name}, '); + buffer.write('message: $message'); + + if (endpoint != null) { + buffer.write(', endpoint: $endpoint'); + } + + if (method != null) { + buffer.write(', method: $method'); + } + + if (statusCode != null) { + buffer.write(', statusCode: $statusCode'); + } + + if (hasFieldErrors) { + buffer.write(', fieldErrors: ${fieldErrors.length}'); + } + + buffer.write(')'); + return buffer.toString(); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ApiError && + other.type == type && + other.message == message && + other.endpoint == endpoint && + other.method == method && + other.statusCode == statusCode && + mapEquals(other.fieldErrors, fieldErrors); + } + + @override + int get hashCode { + return Object.hash( + type, + message, + endpoint, + method, + statusCode, + fieldErrors, + ); + } +} + +/// Types of API errors for categorization and handling +enum ApiErrorType { + network, // Connection issues, DNS resolution, etc. + timeout, // Request timeout (send, receive, connection) + authentication, // 401, invalid credentials, expired tokens + authorization, // 403, insufficient permissions + validation, // 422, field validation errors + badRequest, // 400, malformed request + notFound, // 404, resource not found + server, // 5xx server errors + rateLimit, // 429, too many requests + cancelled, // Request was cancelled + security, // Certificate, SSL/TLS issues + unknown, // Unexpected or unhandled errors +} + +/// Parsed error response from API +/// Contains structured error information from server responses +class ParsedErrorResponse { + const ParsedErrorResponse({ + this.message, + this.code, + this.errors = const [], + this.fieldErrors = const {}, + this.metadata = const {}, + }); + + final String? message; + final String? code; + final List errors; + final Map> fieldErrors; + final Map metadata; + + bool get hasFieldErrors => fieldErrors.isNotEmpty; + bool get hasGeneralErrors => errors.isNotEmpty; + + @override + String toString() { + return 'ParsedErrorResponse(message: $message, errors: ${errors.length}, fieldErrors: ${fieldErrors.length})'; + } +} diff --git a/lib/core/error/api_error_handler.dart b/lib/core/error/api_error_handler.dart new file mode 100644 index 0000000..223f0f3 --- /dev/null +++ b/lib/core/error/api_error_handler.dart @@ -0,0 +1,408 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'api_error.dart'; +import 'error_parser.dart'; + +/// Comprehensive API error handler with structured error parsing +/// Handles all types of API errors and converts them to standardized format +class ApiErrorHandler { + static final ApiErrorHandler _instance = ApiErrorHandler._internal(); + factory ApiErrorHandler() => _instance; + ApiErrorHandler._internal(); + + final ErrorParser _errorParser = ErrorParser(); + + /// Transform any exception into standardized ApiError + ApiError transformError( + dynamic error, { + String? endpoint, + String? method, + Map? requestData, + }) { + try { + if (error is DioException) { + return _handleDioException(error, endpoint: endpoint, method: method); + } else if (error is ApiError) { + return error; + } else { + return ApiError.unknown( + message: 'An unexpected error occurred', + originalError: error, + technical: error.toString(), + ); + } + } catch (e) { + // Fallback error if transformation itself fails + debugPrint('ApiErrorHandler: Error transforming exception: $e'); + return ApiError.unknown( + message: 'A system error occurred', + originalError: error, + technical: 'Error transformation failed: $e', + ); + } + } + + /// Handle DioException with detailed error parsing + ApiError _handleDioException( + DioException dioError, { + String? endpoint, + String? method, + }) { + final statusCode = dioError.response?.statusCode; + final responseData = dioError.response?.data; + final requestPath = endpoint ?? dioError.requestOptions.path; + final httpMethod = method ?? dioError.requestOptions.method; + + // Log error details for debugging + _logErrorDetails(dioError, requestPath, httpMethod); + + switch (dioError.type) { + case DioExceptionType.connectionTimeout: + return ApiError.timeout( + message: 'Connection timeout - please check your internet connection', + endpoint: requestPath, + method: httpMethod, + timeoutDuration: dioError.requestOptions.connectTimeout, + ); + + case DioExceptionType.sendTimeout: + return ApiError.timeout( + message: 'Request send timeout - the upload took too long', + endpoint: requestPath, + method: httpMethod, + timeoutDuration: dioError.requestOptions.sendTimeout, + ); + + case DioExceptionType.receiveTimeout: + return ApiError.timeout( + message: 'Response timeout - the server took too long to respond', + endpoint: requestPath, + method: httpMethod, + timeoutDuration: dioError.requestOptions.receiveTimeout, + ); + + case DioExceptionType.badCertificate: + return ApiError.security( + message: + 'Security certificate error - unable to verify server identity', + endpoint: requestPath, + method: httpMethod, + ); + + case DioExceptionType.connectionError: + return ApiError.network( + message: + 'Network connection error - please check your internet connection', + endpoint: requestPath, + method: httpMethod, + originalError: dioError, + ); + + case DioExceptionType.cancel: + return ApiError.cancelled( + message: 'Request was cancelled', + endpoint: requestPath, + method: httpMethod, + ); + + case DioExceptionType.badResponse: + return _handleBadResponse( + dioError, + requestPath, + httpMethod, + statusCode, + responseData, + ); + + case DioExceptionType.unknown: + return ApiError.unknown( + message: 'An unexpected network error occurred', + endpoint: requestPath, + method: httpMethod, + originalError: dioError, + technical: dioError.message, + ); + } + } + + /// Handle bad response errors with detailed status code analysis + ApiError _handleBadResponse( + DioException dioError, + String requestPath, + String httpMethod, + int? statusCode, + dynamic responseData, + ) { + if (statusCode == null) { + return ApiError.server( + message: 'Invalid server response', + endpoint: requestPath, + method: httpMethod, + statusCode: null, + ); + } + + switch (statusCode) { + case 400: + return _handleBadRequest( + dioError, + requestPath, + httpMethod, + responseData, + ); + + case 401: + return ApiError.authentication( + message: 'Authentication failed - please sign in again', + endpoint: requestPath, + method: httpMethod, + statusCode: statusCode, + ); + + case 403: + return ApiError.authorization( + message: 'Access denied - you don\'t have permission for this action', + endpoint: requestPath, + method: httpMethod, + statusCode: statusCode, + ); + + case 404: + return ApiError.notFound( + message: 'The requested resource was not found', + endpoint: requestPath, + method: httpMethod, + statusCode: statusCode, + ); + + case 422: + return _handleValidationError( + dioError, + requestPath, + httpMethod, + responseData, + ); + + case 429: + return ApiError.rateLimit( + message: 'Too many requests - please wait before trying again', + endpoint: requestPath, + method: httpMethod, + statusCode: statusCode, + retryAfter: _extractRetryAfter(dioError.response?.headers), + ); + + default: + if (statusCode >= 500) { + return _handleServerError( + dioError, + requestPath, + httpMethod, + statusCode, + responseData, + ); + } else { + return ApiError.client( + message: 'Client error occurred', + endpoint: requestPath, + method: httpMethod, + statusCode: statusCode, + details: _errorParser.parseErrorResponse(responseData), + ); + } + } + } + + /// Handle 400 Bad Request with detailed parsing + ApiError _handleBadRequest( + DioException dioError, + String requestPath, + String httpMethod, + dynamic responseData, + ) { + final parsedError = _errorParser.parseErrorResponse(responseData); + + return ApiError.badRequest( + message: + parsedError.message ?? 'Invalid request - please check your input', + endpoint: requestPath, + method: httpMethod, + details: parsedError, + ); + } + + /// Handle 422 Validation Error with field-specific parsing + ApiError _handleValidationError( + DioException dioError, + String requestPath, + String httpMethod, + dynamic responseData, + ) { + final parsedError = _errorParser.parseValidationError(responseData); + + return ApiError.validation( + message: 'Validation failed - please check your input', + endpoint: requestPath, + method: httpMethod, + fieldErrors: parsedError.fieldErrors, + details: parsedError, + ); + } + + /// Handle server errors (5xx) + ApiError _handleServerError( + DioException dioError, + String requestPath, + String httpMethod, + int statusCode, + dynamic responseData, + ) { + final parsedError = _errorParser.parseErrorResponse(responseData); + + String message; + switch (statusCode) { + case 500: + message = 'Internal server error - please try again later'; + break; + case 502: + message = 'Bad gateway - the server is temporarily unavailable'; + break; + case 503: + message = 'Service unavailable - the server is temporarily down'; + break; + case 504: + message = 'Gateway timeout - the server took too long to respond'; + break; + default: + message = 'Server error occurred - please try again later'; + } + + return ApiError.server( + message: message, + endpoint: requestPath, + method: httpMethod, + statusCode: statusCode, + details: parsedError, + ); + } + + /// Extract retry-after header for rate limiting + Duration? _extractRetryAfter(Headers? headers) { + if (headers == null) return null; + + final retryAfterHeader = + headers.value('retry-after') ?? + headers.value('Retry-After') ?? + headers.value('X-RateLimit-Reset-After'); + + if (retryAfterHeader != null) { + final seconds = int.tryParse(retryAfterHeader); + if (seconds != null) { + return Duration(seconds: seconds); + } + } + + return null; + } + + /// Log error details for debugging and monitoring + void _logErrorDetails( + DioException dioError, + String requestPath, + String httpMethod, + ) { + if (kDebugMode) { + debugPrint('🔴 API Error Details:'); + debugPrint(' Method: ${httpMethod.toUpperCase()}'); + debugPrint(' Endpoint: $requestPath'); + debugPrint(' Type: ${dioError.type}'); + debugPrint(' Status: ${dioError.response?.statusCode}'); + + if (dioError.response?.data != null) { + debugPrint(' Response: ${dioError.response?.data}'); + } + + if (dioError.requestOptions.data != null) { + debugPrint(' Request Data: ${dioError.requestOptions.data}'); + } + + debugPrint(' Error: ${dioError.message}'); + } + + // In production, you would send this to your error tracking service + // FirebaseCrashlytics.instance.recordError(dioError, stackTrace); + // Sentry.captureException(dioError); + } + + /// Check if error is retryable + bool isRetryable(ApiError error) { + switch (error.type) { + case ApiErrorType.timeout: + case ApiErrorType.network: + case ApiErrorType.server: + return true; + case ApiErrorType.rateLimit: + return true; // Can retry after waiting + case ApiErrorType.authentication: + return false; // Need new token + case ApiErrorType.authorization: + case ApiErrorType.notFound: + case ApiErrorType.validation: + case ApiErrorType.badRequest: + return false; // Client errors aren't retryable + case ApiErrorType.cancelled: + case ApiErrorType.security: + case ApiErrorType.unknown: + return false; + } + } + + /// Get suggested retry delay for retryable errors + Duration? getRetryDelay(ApiError error) { + if (!isRetryable(error)) return null; + + switch (error.type) { + case ApiErrorType.rateLimit: + return error.retryAfter ?? const Duration(minutes: 1); + case ApiErrorType.timeout: + return const Duration(seconds: 5); + case ApiErrorType.network: + return const Duration(seconds: 3); + case ApiErrorType.server: + return const Duration(seconds: 10); + default: + return const Duration(seconds: 5); + } + } + + /// Get user-friendly error message with actionable advice + String getUserMessage(ApiError error) { + final baseMessage = error.message; + + // Add actionable advice based on error type + switch (error.type) { + case ApiErrorType.network: + return '$baseMessage\n\nPlease check your internet connection and try again.'; + case ApiErrorType.timeout: + return '$baseMessage\n\nThis might be due to a slow connection. Try again in a moment.'; + case ApiErrorType.authentication: + return '$baseMessage\n\nPlease sign in again to continue.'; + case ApiErrorType.authorization: + return '$baseMessage\n\nContact support if you believe this is an error.'; + case ApiErrorType.validation: + return '$baseMessage\n\nPlease correct the highlighted fields and try again.'; + case ApiErrorType.rateLimit: + final delay = error.retryAfter; + if (delay != null) { + final minutes = delay.inMinutes; + final seconds = delay.inSeconds % 60; + return '$baseMessage\n\nPlease wait ${minutes > 0 ? '${minutes}m ' : ''}${seconds}s before trying again.'; + } + return '$baseMessage\n\nPlease wait a moment before trying again.'; + case ApiErrorType.server: + return '$baseMessage\n\nOur servers are experiencing issues. Please try again in a few minutes.'; + default: + return baseMessage; + } + } +} diff --git a/lib/core/error/api_error_interceptor.dart b/lib/core/error/api_error_interceptor.dart new file mode 100644 index 0000000..c147b51 --- /dev/null +++ b/lib/core/error/api_error_interceptor.dart @@ -0,0 +1,239 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'api_error_handler.dart'; +import 'api_error.dart'; + +/// Dio interceptor for automatic error handling and transformation +/// Converts all HTTP errors into standardized ApiError format +class ApiErrorInterceptor extends Interceptor { + final ApiErrorHandler _errorHandler = ApiErrorHandler(); + final bool logErrors; + final bool throwApiErrors; + + ApiErrorInterceptor({this.logErrors = true, this.throwApiErrors = true}); + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + try { + // Transform the error into our standardized format + final apiError = _errorHandler.transformError( + err, + endpoint: err.requestOptions.path, + method: err.requestOptions.method, + ); + + if (logErrors) { + _logApiError(apiError, err); + } + + if (throwApiErrors) { + // Replace the DioException with our ApiError + final enhancedError = DioException( + requestOptions: err.requestOptions, + response: err.response, + type: err.type, + error: apiError, + message: apiError.message, + ); + handler.reject(enhancedError); + } else { + // Store the ApiError in the response extra data + if (err.response != null) { + err.response!.extra['apiError'] = apiError; + } + handler.next(err); + } + } catch (e) { + // Fallback if error transformation fails + debugPrint('ApiErrorInterceptor: Failed to transform error: $e'); + handler.next(err); + } + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + // Check for errors in successful responses (some APIs return errors with 200 status) + if (response.statusCode == 200 && response.data is Map) { + final data = response.data as Map; + + // Check for error indicators in successful responses + if (_isErrorResponse(data)) { + final apiError = _errorHandler.transformError( + data, + endpoint: response.requestOptions.path, + method: response.requestOptions.method, + ); + + if (logErrors) { + debugPrint('🟡 API Error in successful response: $apiError'); + } + + // Store the error for later handling + response.extra['apiError'] = apiError; + } + } + + handler.next(response); + } + + /// Check if a successful response actually contains an error + bool _isErrorResponse(Map data) { + // Common error indicators in successful responses + const errorIndicators = [ + 'error', + 'errors', + 'error_message', + 'errorMessage', + 'success', + ]; + + for (final indicator in errorIndicators) { + if (data.containsKey(indicator)) { + final value = data[indicator]; + + // Check for explicit error indicators + if (indicator == 'success' && value == false) { + return true; + } + + // Check for error messages or arrays + if (indicator != 'success' && value != null) { + if (value is String && value.isNotEmpty) { + return true; + } else if (value is List && value.isNotEmpty) { + return true; + } else if (value is Map && value.isNotEmpty) { + return true; + } + } + } + } + + return false; + } + + /// Log API error with structured information + void _logApiError(ApiError apiError, DioException originalError) { + if (!kDebugMode) return; + + final typeIcon = _getErrorTypeIcon(apiError.type); + debugPrint('$typeIcon API Error [${apiError.type.name.toUpperCase()}]'); + debugPrint(' Method: ${apiError.method?.toUpperCase() ?? 'UNKNOWN'}'); + debugPrint(' Endpoint: ${apiError.endpoint ?? 'unknown'}'); + debugPrint(' Status: ${apiError.statusCode ?? 'N/A'}'); + debugPrint(' Message: ${apiError.message}'); + + if (apiError.hasFieldErrors) { + debugPrint(' Field Errors:'); + for (final entry in apiError.fieldErrors.entries) { + final field = entry.key; + final errors = entry.value; + debugPrint(' $field: ${errors.join(', ')}'); + } + } + + if (apiError.technical != null) { + debugPrint(' Technical: ${apiError.technical}'); + } + + if (apiError.retryAfter != null) { + debugPrint(' Retry After: ${apiError.retryAfter!.inSeconds}s'); + } + + // Log original error type for debugging + debugPrint(' Original Type: ${originalError.type}'); + + // Log request details if available + final requestData = originalError.requestOptions.data; + if (requestData != null && requestData.toString().length < 500) { + debugPrint(' Request: $requestData'); + } + + // Log response data if available and not too large + final responseData = originalError.response?.data; + if (responseData != null && responseData.toString().length < 1000) { + debugPrint(' Response: $responseData'); + } + } + + /// Get emoji icon for error type + String _getErrorTypeIcon(ApiErrorType type) { + switch (type) { + case ApiErrorType.network: + return '🌐'; + case ApiErrorType.timeout: + return '⏱️'; + case ApiErrorType.authentication: + return '🔐'; + case ApiErrorType.authorization: + return '🚫'; + case ApiErrorType.validation: + return '✏️'; + case ApiErrorType.badRequest: + return '❌'; + case ApiErrorType.notFound: + return '🔍'; + case ApiErrorType.server: + return '🔥'; + case ApiErrorType.rateLimit: + return '🐌'; + case ApiErrorType.cancelled: + return '🛑'; + case ApiErrorType.security: + return '🔒'; + case ApiErrorType.unknown: + return '❓'; + } + } + + /// Extract ApiError from DioException if available + static ApiError? extractApiError(DioException error) { + return error.error is ApiError ? error.error as ApiError : null; + } + + /// Extract ApiError from Response if available + static ApiError? extractApiErrorFromResponse(Response response) { + return response.extra['apiError'] as ApiError?; + } + + /// Check if DioException contains an ApiError + static bool hasApiError(DioException error) { + return extractApiError(error) != null; + } + + /// Get user-friendly message from DioException + static String getUserMessage(DioException error) { + final apiError = extractApiError(error); + if (apiError != null) { + return ApiErrorHandler().getUserMessage(apiError); + } + + // Fallback to basic DioException handling + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return 'Connection timeout - please check your internet connection'; + case DioExceptionType.connectionError: + return 'Network connection error - please check your internet connection'; + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + if (statusCode == 401) { + return 'Authentication failed - please sign in again'; + } else if (statusCode == 403) { + return 'Access denied - you don\'t have permission for this action'; + } else if (statusCode == 404) { + return 'The requested resource was not found'; + } else if (statusCode != null && statusCode >= 500) { + return 'Server error occurred - please try again later'; + } + return 'An error occurred with your request'; + case DioExceptionType.cancel: + return 'Request was cancelled'; + case DioExceptionType.badCertificate: + return 'Security certificate error - unable to verify server identity'; + case DioExceptionType.unknown: + return 'An unexpected error occurred - please try again'; + } + } +} diff --git a/lib/core/error/enhanced_error_service.dart b/lib/core/error/enhanced_error_service.dart new file mode 100644 index 0000000..bdbe646 --- /dev/null +++ b/lib/core/error/enhanced_error_service.dart @@ -0,0 +1,467 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'api_error.dart'; +import 'api_error_handler.dart'; +import 'api_error_interceptor.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/theme/theme_extensions.dart'; + +/// Enhanced error service with comprehensive error handling capabilities +/// Provides unified error management across the application +class EnhancedErrorService { + static final EnhancedErrorService _instance = + EnhancedErrorService._internal(); + factory EnhancedErrorService() => _instance; + EnhancedErrorService._internal(); + + final ApiErrorHandler _errorHandler = ApiErrorHandler(); + + /// Transform any error into ApiError format + ApiError transformError( + dynamic error, { + String? endpoint, + String? method, + Map? requestData, + }) { + return _errorHandler.transformError( + error, + endpoint: endpoint, + method: method, + requestData: requestData, + ); + } + + /// Get user-friendly error message + String getUserMessage(dynamic error) { + if (error is ApiError) { + return _errorHandler.getUserMessage(error); + } else if (error is DioException) { + return ApiErrorInterceptor.getUserMessage(error); + } else { + return _getGenericErrorMessage(error); + } + } + + /// Get technical error details for debugging + String getTechnicalDetails(dynamic error) { + if (error is ApiError) { + return error.technical ?? error.toString(); + } else if (error is DioException) { + final apiError = ApiErrorInterceptor.extractApiError(error); + if (apiError != null) { + return apiError.technical ?? apiError.toString(); + } + return '${error.type}: ${error.message}'; + } else { + return error.toString(); + } + } + + /// Check if error is retryable + bool isRetryable(dynamic error) { + if (error is ApiError) { + return _errorHandler.isRetryable(error); + } else if (error is DioException) { + final apiError = ApiErrorInterceptor.extractApiError(error); + if (apiError != null) { + return _errorHandler.isRetryable(apiError); + } + return _isDioErrorRetryable(error); + } + return false; + } + + /// Get suggested retry delay + Duration? getRetryDelay(dynamic error) { + if (error is ApiError) { + return _errorHandler.getRetryDelay(error); + } else if (error is DioException) { + final apiError = ApiErrorInterceptor.extractApiError(error); + if (apiError != null) { + return _errorHandler.getRetryDelay(apiError); + } + return _getDioRetryDelay(error); + } + return null; + } + + /// Show error snackbar with appropriate styling and actions + void showErrorSnackbar( + BuildContext context, + dynamic error, { + VoidCallback? onRetry, + Duration? duration, + bool showTechnicalDetails = false, + }) { + final message = getUserMessage(error); + final isRetryableError = isRetryable(error); + final retryDelay = getRetryDelay(error); + + final snackBar = SnackBar( + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getErrorIcon(error), + color: AppTheme.neutral50, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppTheme.neutral50), + ), + ), + ], + ), + if (showTechnicalDetails) ...[ + const SizedBox(height: Spacing.sm), + Text( + getTechnicalDetails(error), + style: TextStyle( + color: AppTheme.neutral50.withValues(alpha: Alpha.strong), + fontSize: AppTypography.labelMedium, + ), + ), + ], + ], + ), + backgroundColor: _getErrorColor(error), + duration: duration ?? _getSnackbarDuration(error), + action: isRetryableError && onRetry != null + ? SnackBarAction( + label: retryDelay != null && retryDelay.inSeconds > 5 + ? 'Retry (${retryDelay.inSeconds}s)' + : 'Retry', + textColor: AppTheme.neutral50, + onPressed: onRetry, + ) + : null, + ); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + /// Show error dialog with detailed information and recovery options + Future showErrorDialog( + BuildContext context, + dynamic error, { + String? title, + VoidCallback? onRetry, + VoidCallback? onDismiss, + bool showTechnicalDetails = false, + }) async { + final message = getUserMessage(error); + final technicalDetails = getTechnicalDetails(error); + final isRetryableError = isRetryable(error); + + return showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext context) { + return AlertDialog( + title: Row( + children: [ + Icon(_getErrorIcon(error), color: _getErrorColor(error)), + const SizedBox(width: Spacing.sm), + Expanded(child: Text(title ?? _getErrorTitle(error))), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(message), + if (showTechnicalDetails) ...[ + const SizedBox(height: Spacing.md), + const Text( + 'Technical Details:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: Spacing.xs), + Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: AppTheme.neutral100, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + child: Text( + technicalDetails, + style: const TextStyle( + fontFamily: AppTypography.monospaceFontFamily, + fontSize: AppTypography.labelMedium, + ), + ), + ), + ], + ], + ), + actions: [ + if (isRetryableError && onRetry != null) + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onRetry(); + }, + child: const Text('Retry'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onDismiss?.call(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + } + + /// Build error widget for displaying in UI + Widget buildErrorWidget( + dynamic error, { + VoidCallback? onRetry, + bool showTechnicalDetails = false, + EdgeInsets? padding, + }) { + final message = getUserMessage(error); + final technicalDetails = getTechnicalDetails(error); + final isRetryableError = isRetryable(error); + + return Container( + padding: padding ?? const EdgeInsets.all(Spacing.md), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _getErrorIcon(error), + size: IconSize.xxl, + color: _getErrorColor(error), + ), + const SizedBox(height: Spacing.md), + Text( + _getErrorTitle(error), + style: const TextStyle( + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.sm), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: AppTheme.neutral600), + ), + if (showTechnicalDetails) ...[ + const SizedBox(height: Spacing.md), + Container( + padding: const EdgeInsets.all(Spacing.xs), + decoration: BoxDecoration( + color: AppTheme.neutral100, + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Text( + technicalDetails, + style: const TextStyle( + fontFamily: AppTypography.monospaceFontFamily, + fontSize: AppTypography.labelMedium, + ), + ), + ), + ], + if (isRetryableError && onRetry != null) ...[ + const SizedBox(height: Spacing.md), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + ), + ], + ], + ), + ); + } + + /// Log error with structured information + void logError( + dynamic error, { + String? context, + Map? additionalData, + StackTrace? stackTrace, + }) { + if (kDebugMode) { + final timestamp = DateTime.now().toIso8601String(); + debugPrint('🔴 ERROR [$timestamp] ${context ?? 'Unknown Context'}'); + debugPrint(' Message: ${getUserMessage(error)}'); + debugPrint(' Technical: ${getTechnicalDetails(error)}'); + + if (additionalData != null && additionalData.isNotEmpty) { + debugPrint(' Additional Data: $additionalData'); + } + + if (stackTrace != null) { + debugPrint(' Stack Trace: $stackTrace'); + } + } + + // In production, send to error tracking service + // FirebaseCrashlytics.instance.recordError(error, stackTrace, context: context); + // Sentry.captureException(error, stackTrace: stackTrace); + } + + // Private helper methods + + String _getGenericErrorMessage(dynamic error) { + if (error is Exception) { + return 'An error occurred: ${error.toString()}'; + } + return 'An unexpected error occurred'; + } + + bool _isDioErrorRetryable(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.connectionError: + return true; + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + return statusCode != null && statusCode >= 500; + default: + return false; + } + } + + Duration? _getDioRetryDelay(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return const Duration(seconds: 5); + case DioExceptionType.connectionError: + return const Duration(seconds: 3); + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + if (statusCode != null && statusCode >= 500) { + return const Duration(seconds: 10); + } + break; + default: + break; + } + return null; + } + + IconData _getErrorIcon(dynamic error) { + if (error is ApiError) { + switch (error.type) { + case ApiErrorType.network: + return Icons.wifi_off; + case ApiErrorType.timeout: + return Icons.timer_off; + case ApiErrorType.authentication: + return Icons.lock; + case ApiErrorType.authorization: + return Icons.block; + case ApiErrorType.validation: + return Icons.edit_off; + case ApiErrorType.badRequest: + return Icons.error_outline; + case ApiErrorType.notFound: + return Icons.search_off; + case ApiErrorType.server: + return Icons.dns; + case ApiErrorType.rateLimit: + return Icons.speed; + case ApiErrorType.cancelled: + return Icons.cancel; + case ApiErrorType.security: + return Icons.security; + case ApiErrorType.unknown: + return Icons.help_outline; + } + } + return Icons.error_outline; + } + + Color _getErrorColor(dynamic error) { + if (error is ApiError) { + switch (error.type) { + case ApiErrorType.network: + case ApiErrorType.timeout: + return AppTheme.warning; + case ApiErrorType.authentication: + case ApiErrorType.authorization: + return AppTheme.error; + case ApiErrorType.validation: + case ApiErrorType.badRequest: + return AppTheme.warning; + case ApiErrorType.server: + return AppTheme.error; + case ApiErrorType.rateLimit: + return AppTheme.info; + default: + return AppTheme.error; + } + } + return AppTheme.error; + } + + String _getErrorTitle(dynamic error) { + if (error is ApiError) { + switch (error.type) { + case ApiErrorType.network: + return 'Connection Problem'; + case ApiErrorType.timeout: + return 'Request Timeout'; + case ApiErrorType.authentication: + return 'Authentication Required'; + case ApiErrorType.authorization: + return 'Access Denied'; + case ApiErrorType.validation: + return 'Invalid Input'; + case ApiErrorType.badRequest: + return 'Bad Request'; + case ApiErrorType.notFound: + return 'Not Found'; + case ApiErrorType.server: + return 'Server Error'; + case ApiErrorType.rateLimit: + return 'Rate Limited'; + case ApiErrorType.cancelled: + return 'Request Cancelled'; + case ApiErrorType.security: + return 'Security Error'; + case ApiErrorType.unknown: + return 'Unknown Error'; + } + } + return 'Error'; + } + + Duration _getSnackbarDuration(dynamic error) { + if (error is ApiError) { + switch (error.type) { + case ApiErrorType.validation: + case ApiErrorType.badRequest: + return const Duration(seconds: 6); // Longer for validation errors + case ApiErrorType.rateLimit: + return const Duration(seconds: 8); // Longer for rate limits + default: + return const Duration(seconds: 4); + } + } + return const Duration(seconds: 4); + } +} + +/// Global instance for easy access +final enhancedErrorService = EnhancedErrorService(); diff --git a/lib/core/error/error_parser.dart b/lib/core/error/error_parser.dart new file mode 100644 index 0000000..6c0ab8f --- /dev/null +++ b/lib/core/error/error_parser.dart @@ -0,0 +1,405 @@ +import 'package:flutter/foundation.dart'; +import 'api_error.dart'; + +/// Comprehensive error response parser +/// Handles various API error response formats and extracts structured information +class ErrorParser { + /// Parse general error response from API + ParsedErrorResponse parseErrorResponse(dynamic responseData) { + if (responseData == null) { + return const ParsedErrorResponse(); + } + + try { + if (responseData is Map) { + return _parseErrorMap(responseData); + } else if (responseData is String) { + return _parseErrorString(responseData); + } else if (responseData is List) { + return _parseErrorList(responseData); + } else { + return ParsedErrorResponse( + message: 'Unexpected error format', + metadata: {'rawData': responseData.toString()}, + ); + } + } catch (e) { + debugPrint('ErrorParser: Error parsing response: $e'); + return ParsedErrorResponse( + message: 'Failed to parse error response', + metadata: { + 'parseError': e.toString(), + 'rawData': responseData.toString(), + }, + ); + } + } + + /// Parse validation error (422) with field-specific errors + ParsedErrorResponse parseValidationError(dynamic responseData) { + final baseResult = parseErrorResponse(responseData); + + if (responseData is Map) { + final fieldErrors = _extractFieldErrors(responseData); + + return ParsedErrorResponse( + message: baseResult.message ?? 'Validation failed', + code: baseResult.code, + errors: baseResult.errors, + fieldErrors: fieldErrors, + metadata: baseResult.metadata, + ); + } + + return baseResult; + } + + /// Parse error response from a Map (most common format) + ParsedErrorResponse _parseErrorMap(Map data) { + final message = _extractMessage(data); + final code = _extractCode(data); + final errors = _extractGeneralErrors(data); + final fieldErrors = _extractFieldErrors(data); + final metadata = _extractMetadata(data); + + return ParsedErrorResponse( + message: message, + code: code, + errors: errors, + fieldErrors: fieldErrors, + metadata: metadata, + ); + } + + /// Parse error response from a String + ParsedErrorResponse _parseErrorString(String data) { + return ParsedErrorResponse(message: data, metadata: {'format': 'string'}); + } + + /// Parse error response from a List + ParsedErrorResponse _parseErrorList(List data) { + final errors = []; + + for (final item in data) { + if (item is String) { + errors.add(item); + } else if (item is Map) { + final message = _extractMessage(item); + if (message != null) { + errors.add(message); + } + } else { + errors.add(item.toString()); + } + } + + return ParsedErrorResponse( + message: errors.isNotEmpty ? errors.first : 'Multiple errors occurred', + errors: errors, + metadata: {'format': 'list', 'count': data.length}, + ); + } + + /// Extract error message from various possible fields + String? _extractMessage(Map data) { + // Common error message fields in order of preference + const messageFields = [ + 'message', + 'error', + 'detail', + 'description', + 'msg', + 'error_description', + 'title', + 'summary', + ]; + + for (final field in messageFields) { + final value = data[field]; + if (value is String && value.isNotEmpty) { + return value; + } + } + + return null; + } + + /// Extract error code from response + String? _extractCode(Map data) { + const codeFields = [ + 'code', + 'error_code', + 'errorCode', + 'type', + 'error_type', + 'errorType', + ]; + + for (final field in codeFields) { + final value = data[field]; + if (value is String && value.isNotEmpty) { + return value; + } else if (value is int) { + return value.toString(); + } + } + + return null; + } + + /// Extract general error messages (non-field-specific) + List _extractGeneralErrors(Map data) { + final errors = []; + + // Check for error arrays + const errorArrayFields = ['errors', 'messages', 'details', 'issues']; + + for (final field in errorArrayFields) { + final value = data[field]; + if (value is List) { + for (final item in value) { + if (item is String && item.isNotEmpty) { + errors.add(item); + } else if (item is Map) { + final message = _extractMessage(item); + if (message != null) { + errors.add(message); + } + } + } + } + } + + return errors; + } + + /// Extract field-specific validation errors + Map> _extractFieldErrors(Map data) { + final fieldErrors = >{}; + + // Common patterns for field errors + _extractFromFieldErrorsObject(data, fieldErrors); + _extractFromValidationErrorsArray(data, fieldErrors); + _extractFromDetailsObject(data, fieldErrors); + _extractFromOpenAPIFormat(data, fieldErrors); + + return fieldErrors; + } + + /// Extract from 'field_errors' or 'fieldErrors' object + void _extractFromFieldErrorsObject( + Map data, + Map> fieldErrors, + ) { + const fieldErrorFields = [ + 'field_errors', + 'fieldErrors', + 'validation_errors', + 'validationErrors', + 'field_messages', + 'fieldMessages', + ]; + + for (final field in fieldErrorFields) { + final value = data[field]; + if (value is Map) { + for (final entry in value.entries) { + final fieldName = entry.key; + final fieldValue = entry.value; + + final errors = []; + if (fieldValue is String) { + errors.add(fieldValue); + } else if (fieldValue is List) { + for (final item in fieldValue) { + if (item is String) { + errors.add(item); + } else { + errors.add(item.toString()); + } + } + } + + if (errors.isNotEmpty) { + fieldErrors[fieldName] = errors; + } + } + } + } + } + + /// Extract from validation errors array format + void _extractFromValidationErrorsArray( + Map data, + Map> fieldErrors, + ) { + const arrayFields = ['errors', 'details', 'issues']; + + for (final field in arrayFields) { + final value = data[field]; + if (value is List) { + for (final item in value) { + if (item is Map) { + final field = + item['field'] as String? ?? + item['property'] as String? ?? + item['path'] as String?; + final message = _extractMessage(item); + + if (field != null && message != null) { + fieldErrors.putIfAbsent(field, () => []).add(message); + } + } + } + } + } + } + + /// Extract from 'details' object (common in some APIs) + void _extractFromDetailsObject( + Map data, + Map> fieldErrors, + ) { + final details = data['details']; + if (details is Map) { + for (final entry in details.entries) { + final fieldName = entry.key; + final fieldValue = entry.value; + + if (fieldValue is String) { + fieldErrors.putIfAbsent(fieldName, () => []).add(fieldValue); + } else if (fieldValue is List) { + final errors = fieldValue + .map((e) => e.toString()) + .where((s) => s.isNotEmpty) + .toList(); + if (errors.isNotEmpty) { + fieldErrors[fieldName] = errors; + } + } + } + } + } + + /// Extract from OpenAPI specification error format + void _extractFromOpenAPIFormat( + Map data, + Map> fieldErrors, + ) { + // OpenAPI validation errors often come in this format + final detail = data['detail']; + if (detail is List) { + for (final item in detail) { + if (item is Map) { + final loc = item['loc']; + final msg = item['msg'] as String?; + + if (loc is List && loc.isNotEmpty && msg != null) { + // Location can be like ['body', 'fieldName'] or ['fieldName'] + final fieldName = loc.last.toString(); + fieldErrors.putIfAbsent(fieldName, () => []).add(msg); + } + } + } + } + } + + /// Extract additional metadata from error response + Map _extractMetadata(Map data) { + final metadata = {}; + + // Common metadata fields + const metadataFields = [ + 'timestamp', + 'request_id', + 'requestId', + 'trace_id', + 'traceId', + 'correlation_id', + 'correlationId', + 'instance', + 'path', + 'method', + 'status', + 'documentation', + 'help', + 'support', + ]; + + for (final field in metadataFields) { + final value = data[field]; + if (value != null) { + metadata[field] = value; + } + } + + // Include any unrecognized fields as metadata + final recognizedFields = { + 'message', + 'error', + 'detail', + 'description', + 'msg', + 'error_description', + 'title', + 'summary', + 'code', + 'error_code', + 'errorCode', + 'type', + 'error_type', + 'errorType', + 'errors', + 'messages', + 'details', + 'issues', + 'field_errors', + 'fieldErrors', + 'validation_errors', + 'validationErrors', + 'field_messages', + 'fieldMessages', + ...metadataFields, + }; + + for (final entry in data.entries) { + if (!recognizedFields.contains(entry.key)) { + metadata[entry.key] = entry.value; + } + } + + return metadata; + } + + /// Convert field name from API format to user-friendly format + String formatFieldName(String fieldName) { + // Convert snake_case to human readable + if (fieldName.contains('_')) { + return fieldName + .split('_') + .map( + (word) => + word.isEmpty ? word : word[0].toUpperCase() + word.substring(1), + ) + .join(' '); + } + + // Convert camelCase to human readable + return fieldName + .replaceAllMapped(RegExp(r'([A-Z])'), (match) => ' ${match.group(1)}') + .trim(); + } + + /// Get user-friendly error message for a field + String formatFieldError(String fieldName, String error) { + final friendlyFieldName = formatFieldName(fieldName); + + // If error already mentions the field, don't duplicate it + if (error.toLowerCase().contains(fieldName.toLowerCase()) || + error.toLowerCase().contains(friendlyFieldName.toLowerCase())) { + return error; + } + + return '$friendlyFieldName: $error'; + } +} diff --git a/lib/core/models/chat_message.dart b/lib/core/models/chat_message.dart new file mode 100644 index 0000000..92eed78 --- /dev/null +++ b/lib/core/models/chat_message.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_message.freezed.dart'; +part 'chat_message.g.dart'; + +@freezed +sealed class ChatMessage with _$ChatMessage { + const factory ChatMessage({ + required String id, + required String role, // 'user', 'assistant', 'system' + required String content, + required DateTime timestamp, + String? model, + @Default(false) bool isStreaming, + List? attachmentIds, + Map? metadata, + List>? sources, + Map? usage, + }) = _ChatMessage; + + factory ChatMessage.fromJson(Map json) => + _$ChatMessageFromJson(json); +} diff --git a/lib/core/models/conversation.dart b/lib/core/models/conversation.dart new file mode 100644 index 0000000..62bb1e8 --- /dev/null +++ b/lib/core/models/conversation.dart @@ -0,0 +1,27 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'chat_message.dart'; + +part 'conversation.freezed.dart'; +part 'conversation.g.dart'; + +@freezed +sealed class Conversation with _$Conversation { + const factory Conversation({ + required String id, + required String title, + required DateTime createdAt, + required DateTime updatedAt, + String? model, + String? systemPrompt, + @Default([]) List messages, + @Default({}) Map metadata, + @Default(false) bool pinned, + @Default(false) bool archived, + String? shareId, + String? folderId, + @Default([]) List tags, + }) = _Conversation; + + factory Conversation.fromJson(Map json) => + _$ConversationFromJson(json); +} diff --git a/lib/core/models/file_info.dart b/lib/core/models/file_info.dart new file mode 100644 index 0000000..e802a4b --- /dev/null +++ b/lib/core/models/file_info.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'file_info.freezed.dart'; +part 'file_info.g.dart'; + +@freezed +sealed class FileInfo with _$FileInfo { + const factory FileInfo({ + required String id, + required String filename, + required String originalFilename, + required int size, + required String mimeType, + required DateTime createdAt, + required DateTime updatedAt, + String? userId, + String? hash, + Map? metadata, + }) = _FileInfo; + + factory FileInfo.fromJson(Map json) => + _$FileInfoFromJson(json); +} diff --git a/lib/core/models/folder.dart b/lib/core/models/folder.dart new file mode 100644 index 0000000..eba1306 --- /dev/null +++ b/lib/core/models/folder.dart @@ -0,0 +1,41 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'folder.freezed.dart'; +part 'folder.g.dart'; + +// Timestamp converter for Unix timestamps +class TimestampConverter implements JsonConverter { + const TimestampConverter(); + + @override + DateTime fromJson(dynamic json) { + if (json is String) { + return DateTime.parse(json); + } else if (json is int) { + return DateTime.fromMillisecondsSinceEpoch(json * 1000); + } else { + throw ArgumentError('Invalid date format: $json'); + } + } + + @override + dynamic toJson(DateTime object) { + return object.millisecondsSinceEpoch ~/ 1000; + } +} + +@freezed +sealed class Folder with _$Folder { + const factory Folder({ + required String id, + required String name, + @TimestampConverter() required DateTime createdAt, + @TimestampConverter() required DateTime updatedAt, + String? parentId, + @Default([]) List conversationIds, + @Default([]) List subfolders, + @Default({}) Map metadata, + }) = _Folder; + + factory Folder.fromJson(Map json) => _$FolderFromJson(json); +} diff --git a/lib/core/models/knowledge_base.dart b/lib/core/models/knowledge_base.dart new file mode 100644 index 0000000..e85aa2b --- /dev/null +++ b/lib/core/models/knowledge_base.dart @@ -0,0 +1,35 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'knowledge_base.freezed.dart'; +part 'knowledge_base.g.dart'; + +@freezed +sealed class KnowledgeBase with _$KnowledgeBase { + const factory KnowledgeBase({ + required String id, + required String name, + String? description, + required DateTime createdAt, + required DateTime updatedAt, + @Default(0) int itemCount, + @Default({}) Map metadata, + }) = _KnowledgeBase; + + factory KnowledgeBase.fromJson(Map json) => + _$KnowledgeBaseFromJson(json); +} + +@freezed +sealed class KnowledgeBaseItem with _$KnowledgeBaseItem { + const factory KnowledgeBaseItem({ + required String id, + required String content, + String? title, + required DateTime createdAt, + required DateTime updatedAt, + @Default({}) Map metadata, + }) = _KnowledgeBaseItem; + + factory KnowledgeBaseItem.fromJson(Map json) => + _$KnowledgeBaseItemFromJson(json); +} diff --git a/lib/core/models/model.dart b/lib/core/models/model.dart new file mode 100644 index 0000000..27d32f7 --- /dev/null +++ b/lib/core/models/model.dart @@ -0,0 +1,93 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'model.freezed.dart'; + +@freezed +sealed class Model with _$Model { + const Model._(); + + const factory Model({ + required String id, + required String name, + String? description, + @Default(false) bool isMultimodal, + @Default(false) bool supportsStreaming, + @Default(false) bool supportsRAG, + Map? capabilities, + Map? metadata, + List? supportedParameters, + }) = _Model; + + factory Model.fromJson(Map json) { + // Handle different response formats from OpenWebUI + + // Extract architecture info for capabilities + final architecture = json['architecture'] as Map?; + final modality = architecture?['modality'] as String?; + final inputModalities = architecture?['input_modalities'] as List?; + + // Determine if multimodal based on architecture + final isMultimodal = + modality?.contains('image') == true || + inputModalities?.contains('image') == true; + + // Extract supported parameters robustly (top-level or nested under provider keys) + List? supportedParams = + (json['supported_parameters'] as List?) ?? + (json['supportedParameters'] as List?); + + if (supportedParams == null) { + const providerKeys = [ + 'openai', + 'anthropic', + 'google', + 'meta', + 'mistral', + 'cohere', + 'xai', + 'perplexity', + 'deepseek', + 'groq', + ]; + for (final key in providerKeys) { + final provider = json[key] as Map?; + final list = + (provider?['supported_parameters'] as List?) ?? + (provider?['supportedParameters'] as List?); + if (list != null) { + supportedParams = list; + break; + } + } + } + + // Determine streaming support from supported parameters if known + final supportsStreaming = supportedParams?.contains('stream') ?? true; + + // Convert supported parameters to List if present + final supportedParamsList = supportedParams + ?.map((e) => e.toString()) + .toList(); + + return Model( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String?, + isMultimodal: isMultimodal, + supportsStreaming: supportsStreaming, + supportsRAG: json['supportsRAG'] as bool? ?? false, + supportedParameters: supportedParamsList, + capabilities: { + 'architecture': architecture, + 'pricing': json['pricing'], + 'context_length': json['context_length'], + 'supported_parameters': supportedParamsList ?? supportedParams, + }, + metadata: { + 'canonical_slug': json['canonical_slug'], + 'created': json['created'], + 'connection_type': json['connection_type'], + }, + ); + } +} diff --git a/lib/core/models/server_config.dart b/lib/core/models/server_config.dart new file mode 100644 index 0000000..bfa58d4 --- /dev/null +++ b/lib/core/models/server_config.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'server_config.freezed.dart'; +part 'server_config.g.dart'; + +@freezed +sealed class ServerConfig with _$ServerConfig { + const factory ServerConfig({ + required String id, + required String name, + required String url, + String? apiKey, + DateTime? lastConnected, + @Default(false) bool isActive, + }) = _ServerConfig; + + factory ServerConfig.fromJson(Map json) => + _$ServerConfigFromJson(json); +} diff --git a/lib/core/models/user.dart b/lib/core/models/user.dart new file mode 100644 index 0000000..0500acd --- /dev/null +++ b/lib/core/models/user.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'user.freezed.dart'; + +@freezed +sealed class User with _$User { + const User._(); + + const factory User({ + required String id, + required String username, + required String email, + String? name, + String? profileImage, + required String role, + @Default(true) bool isActive, + }) = _User; + + factory User.fromJson(Map json) { + // Handle different field names from OpenWebUI API + return User( + id: json['id'] as String? ?? '', + username: json['username'] as String? ?? json['name'] as String? ?? '', + email: json['email'] as String? ?? '', + name: json['name'] as String?, + profileImage: + json['profile_image_url'] as String? ?? + json['profileImage'] as String?, + role: json['role'] as String? ?? 'user', + isActive: json['is_active'] as bool? ?? json['isActive'] as bool? ?? true, + ); + } +} diff --git a/lib/core/models/user_settings.dart b/lib/core/models/user_settings.dart new file mode 100644 index 0000000..4f50a53 --- /dev/null +++ b/lib/core/models/user_settings.dart @@ -0,0 +1,40 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'user_settings.freezed.dart'; +part 'user_settings.g.dart'; + +@freezed +sealed class UserSettings with _$UserSettings { + const factory UserSettings({ + // Chat preferences + @Default(true) bool showReadReceipts, + @Default(true) bool enableNotifications, + @Default(false) bool enableSounds, + @Default('auto') String theme, // 'light', 'dark', 'auto' + // AI preferences + @Default(0.7) double temperature, + @Default(2048) int maxTokens, + @Default(false) bool streamResponses, + @Default(false) bool webSearchEnabled, + + // Privacy settings + @Default(true) bool saveConversations, + @Default(false) bool shareUsageData, + + // Interface preferences + @Default('comfortable') + String density, // 'compact', 'comfortable', 'spacious' + @Default(14.0) double fontSize, + @Default('en') String language, + + // Accessibility settings + @Default(false) bool reduceMotion, + @Default(true) bool hapticFeedback, + + // Advanced settings + @Default({}) Map customSettings, + }) = _UserSettings; + + factory UserSettings.fromJson(Map json) => + _$UserSettingsFromJson(json); +} diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart new file mode 100644 index 0000000..4e95d3b --- /dev/null +++ b/lib/core/providers/app_providers.dart @@ -0,0 +1,750 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../services/storage_service.dart'; +// (removed duplicate) import '../services/optimized_storage_service.dart'; +import '../services/api_service.dart'; +import '../auth/auth_state_manager.dart'; +import '../../features/auth/providers/unified_auth_providers.dart'; +import '../services/attachment_upload_queue.dart'; +import '../models/server_config.dart'; +import '../models/user.dart'; +import '../models/model.dart'; +import '../models/conversation.dart'; +import '../models/user_settings.dart'; +import '../models/folder.dart'; +import '../models/file_info.dart'; +import '../models/knowledge_base.dart'; +import '../services/optimized_storage_service.dart'; + +// Storage providers +final sharedPreferencesProvider = Provider((ref) { + throw UnimplementedError(); +}); + +final secureStorageProvider = Provider((ref) { + return const FlutterSecureStorage(); +}); + +final storageServiceProvider = Provider((ref) { + return StorageService( + secureStorage: ref.watch(secureStorageProvider), + prefs: ref.watch(sharedPreferencesProvider), + ); +}); + +// Optimized storage service provider +final optimizedStorageServiceProvider = Provider(( + ref, +) { + return OptimizedStorageService( + secureStorage: ref.watch(secureStorageProvider), + prefs: ref.watch(sharedPreferencesProvider), + ); +}); + +// Theme provider +final themeModeProvider = StateNotifierProvider(( + ref, +) { + final storage = ref.watch(optimizedStorageServiceProvider); + return ThemeModeNotifier(storage); +}); + +class ThemeModeNotifier extends StateNotifier { + final OptimizedStorageService _storage; + + ThemeModeNotifier(this._storage) : super(ThemeMode.system) { + _loadTheme(); + } + + void _loadTheme() { + final mode = _storage.getThemeMode(); + if (mode != null) { + state = ThemeMode.values.firstWhere( + (e) => e.toString() == mode, + orElse: () => ThemeMode.system, + ); + } + } + + void setTheme(ThemeMode mode) { + state = mode; + _storage.setThemeMode(mode.toString()); + } +} + +// Server connection providers - optimized with caching +final serverConfigsProvider = FutureProvider>((ref) async { + final storage = ref.watch(optimizedStorageServiceProvider); + return storage.getServerConfigs(); +}); + +final activeServerProvider = FutureProvider((ref) async { + final storage = ref.watch(optimizedStorageServiceProvider); + final configs = await ref.watch(serverConfigsProvider.future); + final activeId = await storage.getActiveServerId(); + + if (activeId == null || configs.isEmpty) return null; + + return configs.firstWhere( + (config) => config.id == activeId, + orElse: () => configs.first, + ); +}); + +final serverConnectionStateProvider = Provider((ref) { + final activeServer = ref.watch(activeServerProvider); + return activeServer.maybeWhen( + data: (server) => server != null, + orElse: () => false, + ); +}); + +// API Service provider with unified auth integration +final apiServiceProvider = Provider((ref) { + // If reviewer mode is enabled, skip creating ApiService + final reviewerMode = ref.watch(reviewerModeProvider); + if (reviewerMode) { + return null; + } + final activeServer = ref.watch(activeServerProvider); + + return activeServer.maybeWhen( + data: (server) { + if (server == null) return null; + + final apiService = ApiService( + serverConfig: server, + authToken: null, // Will be set by auth state manager + ); + + // Keep callbacks in sync so interceptor can notify auth manager + apiService.setAuthCallbacks( + onAuthTokenInvalid: () {}, + onTokenInvalidated: () async { + final authManager = ref.read(authStateManagerProvider.notifier); + await authManager.onTokenInvalidated(); + }, + ); + + // Set up callback for unified auth state manager + // (legacy properties kept during transition) + apiService.onTokenInvalidated = () async { + final authManager = ref.read(authStateManagerProvider.notifier); + await authManager.onTokenInvalidated(); + }; + + // Keep legacy callback for backward compatibility during transition + apiService.onAuthTokenInvalid = () { + // This will be removed once migration is complete + debugPrint('DEBUG: Legacy auth invalidation callback triggered'); + }; + + // Initialize with any existing token immediately + final token = ref.read(authTokenProvider3); + if (token != null && token.isNotEmpty) { + apiService.updateAuthToken(token); + } + + return apiService; + }, + orElse: () => null, + ); +}); + +// Attachment upload queue provider +final attachmentUploadQueueProvider = Provider((ref) { + final api = ref.watch(apiServiceProvider); + if (api == null) return null; + + final queue = AttachmentUploadQueue(); + // Initialize once; subsequent calls are no-ops due to singleton + queue.initialize( + onUpload: (filePath, fileName) => api.uploadFile(filePath, fileName), + ); + + return queue; +}); + +// Auth providers +// Auth token integration with API service - using unified auth system +final apiTokenUpdaterProvider = Provider((ref) { + // Listen to unified auth token changes and update API service + ref.listen(authTokenProvider3, (previous, next) { + final api = ref.read(apiServiceProvider); + if (api != null && next != null && next.isNotEmpty) { + api.updateAuthToken(next); + debugPrint('DEBUG: Updated API service with unified auth token'); + } + }); +}); + +final currentUserProvider = FutureProvider((ref) async { + final api = ref.read(apiServiceProvider); + final isAuthenticated = ref.watch(isAuthenticatedProvider2); + + if (api == null || !isAuthenticated) return null; + + try { + return await api.getCurrentUser(); + } catch (e) { + return null; + } +}); + +// Helper provider to force refresh auth state - now using unified system +final refreshAuthStateProvider = Provider((ref) { + // This provider can be invalidated to force refresh the unified auth system + ref.read(refreshAuthProvider); + return; +}); + +// Model providers +final modelsProvider = FutureProvider>((ref) async { + // Reviewer mode returns mock models + final reviewerMode = ref.watch(reviewerModeProvider); + if (reviewerMode) { + return [ + const Model( + id: 'demo/gemma-2-mini', + name: 'Gemma 2 Mini (Demo)', + description: 'Demo model for reviewer mode', + isMultimodal: true, + supportsStreaming: true, + supportedParameters: ['max_tokens', 'stream'], + ), + const Model( + id: 'demo/llama-3-8b', + name: 'Llama 3 8B (Demo)', + description: 'Fast text model for demo', + isMultimodal: false, + supportsStreaming: true, + supportedParameters: ['max_tokens', 'stream'], + ), + ]; + } + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + debugPrint('DEBUG: Fetching models from server'); + final models = await api.getModels(); + debugPrint('DEBUG: Successfully fetched ${models.length} models'); + return models; + } catch (e) { + debugPrint('ERROR: Failed to fetch models: $e'); + + // If models endpoint returns 403, this should now clear auth token + // and redirect user to login since it's marked as a core endpoint + if (e.toString().contains('403')) { + debugPrint( + 'DEBUG: Models endpoint returned 403 - authentication may be invalid', + ); + } + + return []; + } +}); + +final selectedModelProvider = StateProvider((ref) => null); + +// Conversation providers - Now using correct OpenWebUI API +final conversationsProvider = FutureProvider>((ref) async { + final reviewerMode = ref.watch(reviewerModeProvider); + if (reviewerMode) { + // Provide a simple local demo conversation list + return [ + Conversation( + id: 'demo-conv-1', + title: 'Welcome to Conduit (Demo)', + createdAt: DateTime.now().subtract(const Duration(minutes: 15)), + updatedAt: DateTime.now().subtract(const Duration(minutes: 10)), + messages: [], + ), + ]; + } + final api = ref.watch(apiServiceProvider); + if (api == null) { + debugPrint('DEBUG: No API service available'); + return []; + } + + try { + debugPrint('DEBUG: Fetching conversations from OpenWebUI API...'); + final conversations = await api.getConversations(limit: 50); + debugPrint( + 'DEBUG: Successfully fetched ${conversations.length} conversations', + ); + return conversations; + } catch (e, stackTrace) { + debugPrint('DEBUG: Error fetching conversations: $e'); + debugPrint('DEBUG: Stack trace: $stackTrace'); + + // If conversations endpoint returns 403, this should now clear auth token + // and redirect user to login since it's marked as a core endpoint + if (e.toString().contains('403')) { + debugPrint( + 'DEBUG: Conversations endpoint returned 403 - authentication may be invalid', + ); + } + + // Return empty list instead of re-throwing to allow app to continue functioning + return []; + } +}); + +final activeConversationProvider = StateProvider((ref) => null); + +// Provider to load full conversation with messages +final loadConversationProvider = FutureProvider.family(( + ref, + conversationId, +) async { + final api = ref.watch(apiServiceProvider); + if (api == null) { + throw Exception('No API service available'); + } + + debugPrint('DEBUG: Loading full conversation: $conversationId'); + final fullConversation = await api.getConversation(conversationId); + debugPrint( + 'DEBUG: Loaded conversation with ${fullConversation.messages.length} messages', + ); + + return fullConversation; +}); + +// Provider to automatically load and set the default model from OpenWebUI +final defaultModelProvider = FutureProvider((ref) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return null; + + try { + // Get all available models first + final models = await ref.read(modelsProvider.future); + if (models.isEmpty) { + debugPrint('DEBUG: No models available'); + return null; + } + + // Check if a model is already selected + final currentSelected = ref.read(selectedModelProvider); + if (currentSelected != null) { + debugPrint('DEBUG: Model already selected: ${currentSelected.name}'); + return currentSelected; + } + + Model? selectedModel; + + // Try to get the server's default model configuration + try { + final defaultModelId = await api.getDefaultModel(); + + if (defaultModelId != null && defaultModelId.isNotEmpty) { + // Find the model that matches the default model ID + try { + selectedModel = models.firstWhere( + (model) => + model.id == defaultModelId || + model.name == defaultModelId || + model.id.contains(defaultModelId) || + model.name.contains(defaultModelId), + ); + debugPrint( + 'DEBUG: Found server default model: ${selectedModel.name}', + ); + } catch (e) { + debugPrint( + 'DEBUG: Default model "$defaultModelId" not found in available models', + ); + selectedModel = models.first; + } + } else { + // No server default, use first available model + selectedModel = models.first; + debugPrint( + 'DEBUG: No server default model, using first available: ${selectedModel.name}', + ); + } + } catch (apiError) { + debugPrint('DEBUG: Failed to get default model from server: $apiError'); + // Use first available model as fallback + selectedModel = models.first; + debugPrint( + 'DEBUG: Using first available model as fallback: ${selectedModel.name}', + ); + } + + // Set the selected model + ref.read(selectedModelProvider.notifier).state = selectedModel; + debugPrint('DEBUG: Set default model: ${selectedModel.name}'); + + return selectedModel; + } catch (e) { + debugPrint('DEBUG: Error setting default model: $e'); + + // Final fallback: try to select any available model + try { + final models = await ref.read(modelsProvider.future); + if (models.isNotEmpty) { + final fallbackModel = models.first; + ref.read(selectedModelProvider.notifier).state = fallbackModel; + debugPrint( + 'DEBUG: Fallback to first available model: ${fallbackModel.name}', + ); + return fallbackModel; + } + } catch (fallbackError) { + debugPrint('DEBUG: Error in fallback model selection: $fallbackError'); + } + + return null; + } +}); + +// Background model loading provider that doesn't block UI +// This just schedules the loading, doesn't wait for it +final backgroundModelLoadProvider = Provider((ref) { + // Ensure API token updater is initialized + ref.watch(apiTokenUpdaterProvider); + + // Schedule background loading without blocking + Future.microtask(() async { + // Wait a bit to ensure auth is complete + await Future.delayed(const Duration(milliseconds: 1500)); + + debugPrint('DEBUG: Starting background model loading'); + + // Load default model in background + try { + await ref.read(defaultModelProvider.future); + debugPrint('DEBUG: Background model loading completed'); + } catch (e) { + // Ignore errors in background loading + debugPrint('DEBUG: Background model loading failed: $e'); + } + }); + + // Return immediately, don't block the UI + return; +}); + +// Search query provider +final searchQueryProvider = StateProvider((ref) => ''); + +// Server-side search provider for chats +final serverSearchProvider = FutureProvider.family, String>(( + ref, + query, +) async { + if (query.trim().isEmpty) { + // Return empty list for empty query instead of all conversations + return []; + } + + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + debugPrint('DEBUG: Performing server-side search for: "$query"'); + + // Use the new server-side search API + final searchResult = await api.searchChats( + query: query.trim(), + archived: false, // Only search non-archived conversations + limit: 50, + sortBy: 'updated_at', + sortOrder: 'desc', + ); + + // Extract conversations from search result + final List conversationsData = searchResult['conversations'] ?? []; + + // Convert to Conversation objects + final List conversations = conversationsData.map((data) { + return Conversation.fromJson(data as Map); + }).toList(); + + debugPrint('DEBUG: Server search returned ${conversations.length} results'); + return conversations; + } catch (e) { + debugPrint('DEBUG: Server search failed, fallback to local: $e'); + + // Fallback to local search if server search fails + final allConversations = await ref.read(conversationsProvider.future); + return allConversations.where((conv) { + return !conv.archived && + (conv.title.toLowerCase().contains(query.toLowerCase()) || + conv.messages.any( + (msg) => + msg.content.toLowerCase().contains(query.toLowerCase()), + )); + }).toList(); + } +}); + +final filteredConversationsProvider = Provider>((ref) { + final conversations = ref.watch(conversationsProvider); + final query = ref.watch(searchQueryProvider); + + // Use server-side search when there's a query + if (query.trim().isNotEmpty) { + final searchResults = ref.watch(serverSearchProvider(query)); + return searchResults.maybeWhen( + data: (results) => results, + loading: () { + // While server search is loading, show local filtered results + return conversations.maybeWhen( + data: (convs) => convs.where((conv) { + return !conv.archived && + (conv.title.toLowerCase().contains(query.toLowerCase()) || + conv.messages.any( + (msg) => msg.content.toLowerCase().contains( + query.toLowerCase(), + ), + )); + }).toList(), + orElse: () => [], + ); + }, + error: (_, stackTrace) { + // On error, fallback to local search + return conversations.maybeWhen( + data: (convs) => convs.where((conv) { + return !conv.archived && + (conv.title.toLowerCase().contains(query.toLowerCase()) || + conv.messages.any( + (msg) => msg.content.toLowerCase().contains( + query.toLowerCase(), + ), + )); + }).toList(), + orElse: () => [], + ); + }, + orElse: () => [], + ); + } + + // When no search query, show all non-archived conversations + return conversations.maybeWhen( + data: (convs) { + if (ref.watch(reviewerModeProvider)) { + return convs; // Already filtered above for demo + } + // Filter out archived conversations (they should be in a separate view) + final filtered = convs.where((conv) => !conv.archived).toList(); + + // Sort: pinned conversations first, then by updated date + filtered.sort((a, b) { + // Pinned conversations come first + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + + // Within same pin status, sort by updated date (newest first) + return b.updatedAt.compareTo(a.updatedAt); + }); + + return filtered; + }, + orElse: () => [], + ); +}); + +// Provider for archived conversations +final archivedConversationsProvider = Provider>((ref) { + final conversations = ref.watch(conversationsProvider); + + return conversations.maybeWhen( + data: (convs) { + if (ref.watch(reviewerModeProvider)) { + return convs.where((c) => c.archived).toList(); + } + // Only show archived conversations + final archived = convs.where((conv) => conv.archived).toList(); + + // Sort by updated date (newest first) + archived.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + return archived; + }, + orElse: () => [], + ); +}); + +// Reviewer mode provider (persisted) +final reviewerModeProvider = StateNotifierProvider( + (ref) => ReviewerModeNotifier(ref.watch(optimizedStorageServiceProvider)), +); + +class ReviewerModeNotifier extends StateNotifier { + final OptimizedStorageService _storage; + ReviewerModeNotifier(this._storage) : super(false) { + _load(); + } + Future _load() async { + final enabled = await _storage.getReviewerMode(); + state = enabled; + } + + Future setEnabled(bool enabled) async { + state = enabled; + await _storage.setReviewerMode(enabled); + } + + Future toggle() => setEnabled(!state); +} + +// User Settings providers +final userSettingsProvider = FutureProvider((ref) async { + final api = ref.watch(apiServiceProvider); + if (api == null) { + // Return default settings if no API + return const UserSettings(); + } + + try { + final settingsData = await api.getUserSettings(); + return UserSettings.fromJson(settingsData); + } catch (e) { + debugPrint('DEBUG: Error fetching user settings: $e'); + // Return default settings on error + return const UserSettings(); + } +}); + +// Server Banners provider +final serverBannersProvider = FutureProvider>>(( + ref, +) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + return await api.getBanners(); + } catch (e) { + debugPrint('DEBUG: Error fetching banners: $e'); + return []; + } +}); + +// Conversation Suggestions provider +final conversationSuggestionsProvider = FutureProvider>(( + ref, +) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + return await api.getSuggestions(); + } catch (e) { + debugPrint('DEBUG: Error fetching suggestions: $e'); + return []; + } +}); + +// Folders provider +final foldersProvider = FutureProvider>((ref) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + final foldersData = await api.getFolders(); + return foldersData + .map((folderData) => Folder.fromJson(folderData)) + .toList(); + } catch (e) { + debugPrint('DEBUG: Error fetching folders: $e'); + return []; + } +}); + +// Files provider +final userFilesProvider = FutureProvider>((ref) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + final filesData = await api.getUserFiles(); + return filesData.map((fileData) => FileInfo.fromJson(fileData)).toList(); + } catch (e) { + debugPrint('DEBUG: Error fetching files: $e'); + return []; + } +}); + +// File content provider +final fileContentProvider = FutureProvider.family(( + ref, + fileId, +) async { + final api = ref.watch(apiServiceProvider); + if (api == null) throw Exception('No API service available'); + + try { + return await api.getFileContent(fileId); + } catch (e) { + debugPrint('DEBUG: Error fetching file content: $e'); + throw Exception('Failed to load file content: $e'); + } +}); + +// Knowledge Base providers +final knowledgeBasesProvider = FutureProvider>((ref) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + final kbData = await api.getKnowledgeBases(); + return kbData.map((data) => KnowledgeBase.fromJson(data)).toList(); + } catch (e) { + debugPrint('DEBUG: Error fetching knowledge bases: $e'); + return []; + } +}); + +final knowledgeBaseItemsProvider = + FutureProvider.family, String>((ref, kbId) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + final itemsData = await api.getKnowledgeBaseItems(kbId); + return itemsData + .map((data) => KnowledgeBaseItem.fromJson(data)) + .toList(); + } catch (e) { + debugPrint('DEBUG: Error fetching knowledge base items: $e'); + return []; + } + }); + +// Audio providers +final availableVoicesProvider = FutureProvider>((ref) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + return await api.getAvailableVoices(); + } catch (e) { + debugPrint('DEBUG: Error fetching voices: $e'); + return []; + } +}); + +// Image Generation providers +final imageModelsProvider = FutureProvider>>(( + ref, +) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return []; + + try { + return await api.getImageModels(); + } catch (e) { + debugPrint('DEBUG: Error fetching image models: $e'); + return []; + } +}); diff --git a/lib/core/services/animation_service.dart b/lib/core/services/animation_service.dart new file mode 100644 index 0000000..960ebc0 --- /dev/null +++ b/lib/core/services/animation_service.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../shared/theme/theme_extensions.dart'; + +/// Service for managing animations with performance optimization and accessibility +class AnimationService { + /// Get optimized animation duration based on context and settings + static Duration getOptimizedDuration( + BuildContext context, + Duration defaultDuration, { + bool respectReducedMotion = true, + }) { + if (respectReducedMotion && MediaQuery.of(context).disableAnimations) { + return Duration.zero; + } + + // Optimize for 60fps - keep animations under 300ms for snappy feel + final optimizedDuration = Duration( + milliseconds: (defaultDuration.inMilliseconds * 0.8).round().clamp( + 100, + 300, + ), + ); + + return optimizedDuration; + } + + /// Get optimized curve for smooth 60fps animations + static Curve getOptimizedCurve({Curve defaultCurve = Curves.easeInOut}) { + // Use curves that are optimized for mobile performance + final curveType = defaultCurve.runtimeType.toString(); + + // Replace performance-heavy curves with lighter alternatives + if (curveType.contains('Bounce')) { + return Curves.easeInOutQuart; // Replace heavy bounce with smooth curve + } else if (curveType.contains('Elastic')) { + return Curves.easeInOutBack; // Lighter alternative to elastic + } else if (defaultCurve == Curves.easeInOut) { + return Curves.easeInOutCubic; // Better performance than default + } + + return defaultCurve; + } + + /// Create performant fade transition + static Widget createOptimizedFadeTransition({ + required Widget child, + required Animation animation, + Duration? duration, + }) { + return FadeTransition(opacity: animation, child: child); + } + + /// Create performant slide transition + static Widget createOptimizedSlideTransition({ + required Widget child, + required Animation animation, + Duration? duration, + }) { + return SlideTransition(position: animation, child: child); + } + + /// Create performant scale transition + static Widget createOptimizedScaleTransition({ + required Widget child, + required Animation animation, + Duration? duration, + }) { + return ScaleTransition(scale: animation, child: child); + } + + /// Create optimized page transition + static PageRouteBuilder createOptimizedPageRoute({ + required Widget page, + Duration? transitionDuration, + PageTransitionType type = PageTransitionType.slide, + }) { + return PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: + transitionDuration ?? const Duration(milliseconds: 250), + reverseTransitionDuration: + transitionDuration ?? const Duration(milliseconds: 200), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final optimizedCurve = getOptimizedCurve(); + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: optimizedCurve, + ); + + switch (type) { + case PageTransitionType.fade: + return FadeTransition(opacity: curvedAnimation, child: child); + case PageTransitionType.slide: + return SlideTransition( + position: Tween( + begin: const Offset(1.0, 0.0), + end: Offset.zero, + ).animate(curvedAnimation), + child: child, + ); + case PageTransitionType.scale: + return ScaleTransition( + scale: Tween( + begin: 0.8, + end: 1.0, + ).animate(curvedAnimation), + child: FadeTransition(opacity: curvedAnimation, child: child), + ); + } + }, + ); + } + + /// Create staggered animation for lists + static Widget createStaggeredListAnimation({ + required Widget child, + required int index, + Duration? delay, + Duration? duration, + }) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: duration ?? const Duration(milliseconds: 200), + curve: getOptimizedCurve(), + builder: (context, value, child) { + return Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: Opacity(opacity: value, child: child), + ); + }, + child: child, + ); + } + + /// Create performant shimmer animation + static Widget createOptimizedShimmer({ + required Widget child, + Duration? duration, + Color? baseColor, + Color? highlightColor, + }) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: duration ?? const Duration(milliseconds: 1500), + curve: Curves.linear, + builder: (context, value, child) { + return ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + baseColor ?? context.conduitTheme.shimmerBase, + highlightColor ?? context.conduitTheme.shimmerHighlight, + baseColor ?? context.conduitTheme.shimmerBase, + ], + stops: [0.0, value, 1.0], + ).createShader(bounds); + }, + child: child, + ); + }, + child: child, + ); + } + + /// Create optimized rotation animation + static Widget createOptimizedRotation({ + required Widget child, + required Animation animation, + double turns = 1.0, + }) { + return RotationTransition( + turns: Tween(begin: 0, end: turns).animate(animation), + child: child, + ); + } + + /// Check if device can handle complex animations + static bool canHandleComplexAnimations(BuildContext context) { + // Simple heuristic based on screen density and platform + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final screenSize = MediaQuery.of(context).size; + final totalPixels = screenSize.width * screenSize.height * devicePixelRatio; + + // If total pixels exceed 4M, assume it's a high-end device + return totalPixels > 4000000; + } + + /// Create adaptive animation based on device capability + static Widget createAdaptiveAnimation({ + required BuildContext context, + required Widget child, + required Widget Function(Widget) complexAnimation, + required Widget Function(Widget) simpleAnimation, + }) { + if (canHandleComplexAnimations(context) && + !MediaQuery.of(context).disableAnimations) { + return complexAnimation(child); + } else { + return simpleAnimation(child); + } + } +} + +/// Enum for page transition types +enum PageTransitionType { fade, slide, scale } + +/// Provider for reduced motion preference +final reducedMotionProvider = StateProvider((ref) => false); + +/// Provider for animation performance settings +final animationPerformanceProvider = StateProvider((ref) { + return AnimationPerformance.adaptive; +}); + +/// Animation performance levels +enum AnimationPerformance { + high, // All animations enabled + adaptive, // Adaptive based on device + reduced, // Simplified animations + minimal, // Essential animations only +} + +/// Provider for managing animation settings +final animationSettingsProvider = + StateNotifierProvider( + (ref) => AnimationSettingsNotifier(), + ); + +class AnimationSettings { + final bool reduceMotion; + final AnimationPerformance performance; + final double animationSpeed; + + const AnimationSettings({ + this.reduceMotion = false, + this.performance = AnimationPerformance.adaptive, + this.animationSpeed = 1.0, + }); + + AnimationSettings copyWith({ + bool? reduceMotion, + AnimationPerformance? performance, + double? animationSpeed, + }) { + return AnimationSettings( + reduceMotion: reduceMotion ?? this.reduceMotion, + performance: performance ?? this.performance, + animationSpeed: animationSpeed ?? this.animationSpeed, + ); + } +} + +class AnimationSettingsNotifier extends StateNotifier { + AnimationSettingsNotifier() : super(const AnimationSettings()); + + void setReduceMotion(bool reduce) { + state = state.copyWith(reduceMotion: reduce); + } + + void setPerformance(AnimationPerformance performance) { + state = state.copyWith(performance: performance); + } + + void setAnimationSpeed(double speed) { + state = state.copyWith(animationSpeed: speed.clamp(0.5, 2.0)); + } + + Duration adjustDuration(Duration baseDuration) { + if (state.reduceMotion) return Duration.zero; + + final adjustedMs = (baseDuration.inMilliseconds / state.animationSpeed) + .round(); + return Duration(milliseconds: adjustedMs); + } +} diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart new file mode 100644 index 0000000..d7d375f --- /dev/null +++ b/lib/core/services/api_service.dart @@ -0,0 +1,3522 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:dio/dio.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:socket_io_client/socket_io_client.dart' as io; +import 'package:uuid/uuid.dart'; +import '../models/server_config.dart'; +import '../models/user.dart'; +import '../models/model.dart'; +import '../models/conversation.dart'; +import '../models/chat_message.dart'; +import '../auth/api_auth_interceptor.dart'; +import '../validation/validation_interceptor.dart'; +import '../error/api_error_interceptor.dart'; + +class ApiService { + final Dio _dio; + final ServerConfig serverConfig; + late final ApiAuthInterceptor _authInterceptor; + WebSocketChannel? _wsChannel; + io.Socket? _socket; + + // Callback to notify when auth token becomes invalid + void Function()? onAuthTokenInvalid; + + // New callback for the unified auth state manager + Future Function()? onTokenInvalidated; + + ApiService({required this.serverConfig, String? authToken}) + : _dio = Dio( + BaseOptions( + baseUrl: serverConfig.url, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + followRedirects: true, + maxRedirects: 5, + validateStatus: (status) => status != null && status < 400, + ), + ) { + // Initialize the consistent auth interceptor + _authInterceptor = ApiAuthInterceptor( + authToken: authToken, + onAuthTokenInvalid: onAuthTokenInvalid, + onTokenInvalidated: onTokenInvalidated, + ); + + // Add interceptors in order of priority: + // 1. Auth interceptor (must be first to add auth headers) + _dio.interceptors.add(_authInterceptor); + + // 2. Validation interceptor (validates requests/responses against OpenAPI schema) + final validationInterceptor = ValidationInterceptor( + enableRequestValidation: true, + enableResponseValidation: true, + throwOnValidationError: false, // Don't throw, just log validation issues + logValidationResults: kDebugMode, + ); + _dio.interceptors.add(validationInterceptor); + + // 3. Error handling interceptor (transforms errors to standardized format) + _dio.interceptors.add( + ApiErrorInterceptor( + logErrors: kDebugMode, + throwApiErrors: true, // Transform DioExceptions to include ApiError + ), + ); + + // 4. Logging interceptor for debugging (should be last to see final requests/responses) + if (kDebugMode) { + _dio.interceptors.add( + LogInterceptor( + requestBody: true, + responseBody: false, // Don't log response bodies to reduce noise + requestHeader: true, + responseHeader: false, + error: true, + logPrint: (obj) => debugPrint('API: $obj'), + ), + ); + } + + // Initialize validation interceptor asynchronously + validationInterceptor.initialize().catchError((error) { + debugPrint( + 'ApiService: Failed to initialize validation interceptor: $error', + ); + }); + } + + void updateAuthToken(String token) { + _authInterceptor.updateAuthToken(token); + } + + String? get authToken => _authInterceptor.authToken; + + /// Ensure interceptor callbacks stay in sync if they are set after construction + void setAuthCallbacks({ + void Function()? onAuthTokenInvalid, + Future Function()? onTokenInvalidated, + }) { + if (onAuthTokenInvalid != null) { + this.onAuthTokenInvalid = onAuthTokenInvalid; + _authInterceptor.onAuthTokenInvalid = onAuthTokenInvalid; + } + if (onTokenInvalidated != null) { + this.onTokenInvalidated = onTokenInvalidated; + _authInterceptor.onTokenInvalidated = onTokenInvalidated; + } + } + + // Health check + Future checkHealth() async { + try { + final response = await _dio.get('/health'); + return response.statusCode == 200; + } catch (e) { + return false; + } + } + + // Enhanced health check with model availability + Future> checkServerStatus() async { + final result = { + 'healthy': false, + 'modelsAvailable': false, + 'modelCount': 0, + 'error': null, + }; + + try { + // Check basic health + final healthResponse = await _dio.get('/health'); + result['healthy'] = healthResponse.statusCode == 200; + + if (result['healthy']) { + // Check model availability + try { + final modelsResponse = await _dio.get('/api/models'); + final models = modelsResponse.data['data'] as List?; + result['modelsAvailable'] = models != null && models.isNotEmpty; + result['modelCount'] = models?.length ?? 0; + } catch (e) { + debugPrint('DEBUG: Error checking models: $e'); + result['modelsAvailable'] = false; + } + } + } catch (e) { + result['error'] = e.toString(); + debugPrint('DEBUG: Server status check failed: $e'); + } + + return result; + } + + // Authentication + Future> login(String username, String password) async { + try { + debugPrint( + 'DEBUG: Attempting login to ${serverConfig.url}/api/v1/auths/signin', + ); + final response = await _dio.post( + '/api/v1/auths/signin', + data: {'email': username, 'password': password}, + ); + debugPrint('DEBUG: Login successful, status: ${response.statusCode}'); + return response.data; + } catch (e) { + if (e is DioException) { + debugPrint('DEBUG: Login DioException: ${e.type}'); + debugPrint('DEBUG: Response status: ${e.response?.statusCode}'); + debugPrint('DEBUG: Response headers: ${e.response?.headers}'); + debugPrint('DEBUG: Request URL: ${e.requestOptions.uri}'); + + // Handle specific redirect cases + if (e.response?.statusCode == 307 || e.response?.statusCode == 308) { + final location = e.response?.headers.value('location'); + if (location != null) { + debugPrint('DEBUG: Server redirecting to: $location'); + throw Exception( + 'Server redirect detected. Please check your server URL configuration. Redirect to: $location', + ); + } + } + } + rethrow; + } + } + + Future logout() async { + await _dio.get('/api/v1/auths/signout'); + } + + // User info + Future getCurrentUser() async { + final response = await _dio.get('/api/v1/auths/'); + debugPrint('DEBUG: /api/v1/auths/ response: ${jsonEncode(response.data)}'); + return User.fromJson(response.data); + } + + // Models + Future> getModels() async { + final response = await _dio.get('/api/models'); + debugPrint('DEBUG: /api/models raw response: ${jsonEncode(response.data)}'); + + // Handle different response formats + List models; + if (response.data is Map && response.data['data'] != null) { + // Response is wrapped in a 'data' field + models = response.data['data'] as List; + } else if (response.data is List) { + // Response is a direct array + models = response.data as List; + } else { + debugPrint('ERROR: Unexpected models response format'); + return []; + } + + debugPrint('DEBUG: Found ${models.length} models'); + return models.map((m) => Model.fromJson(m)).toList(); + } + + // Get default model configuration from OpenWebUI user settings + Future getDefaultModel() async { + try { + debugPrint('DEBUG: Fetching default model from user settings'); + final response = await _dio.get('/api/v1/users/user/settings'); + + debugPrint('DEBUG: User settings response: ${jsonEncode(response.data)}'); + + final settings = response.data as Map; + + // Extract default model from ui.models array + final ui = settings['ui'] as Map?; + if (ui != null) { + final models = ui['models'] as List?; + if (models != null && models.isNotEmpty) { + // Return the first model in the user's preferred models list + final defaultModel = models.first.toString(); + debugPrint( + 'DEBUG: Found default model from user settings: $defaultModel', + ); + return defaultModel; + } + } + + debugPrint('DEBUG: No default model found in user settings'); + return null; + } catch (e) { + debugPrint('DEBUG: Error fetching default model from user settings: $e'); + // Fall back to trying the old endpoint + try { + debugPrint('DEBUG: Falling back to configs/models endpoint'); + final response = await _dio.get('/api/v1/configs/models'); + final config = response.data as Map; + + final defaultModel = + config['DEFAULT_MODELS'] as String? ?? + config['default_models'] as String? ?? + config['default_model'] as String?; + + if (defaultModel != null && defaultModel.isNotEmpty) { + debugPrint('DEBUG: Found default model from fallback: $defaultModel'); + return defaultModel; + } + } catch (fallbackError) { + debugPrint('DEBUG: Fallback also failed: $fallbackError'); + } + + return null; + } + } + + // Conversations - Updated to use correct OpenWebUI API + Future> getConversations({int? limit, int? skip}) async { + debugPrint('DEBUG: Fetching conversations from OpenWebUI API'); + debugPrint('DEBUG: Making request to ${serverConfig.url}/api/v1/chats/'); + debugPrint('DEBUG: Auth token present: ${authToken != null}'); + + // Fetch regular, pinned, and archived conversations + final regularResponse = await _dio.get( + '/api/v1/chats/', + queryParameters: { + if (limit != null && limit > 0) + 'page': ((skip ?? 0) / limit) + .floor(), // OpenWebUI uses page-based pagination with proper bounds checking + }, + ); + + final pinnedResponse = await _dio.get('/api/v1/chats/pinned'); + final archivedResponse = await _dio.get('/api/v1/chats/all/archived'); + + debugPrint('DEBUG: Regular response status: ${regularResponse.statusCode}'); + debugPrint('DEBUG: Pinned response status: ${pinnedResponse.statusCode}'); + debugPrint( + 'DEBUG: Archived response status: ${archivedResponse.statusCode}', + ); + + if (regularResponse.data is! List) { + throw Exception( + 'Expected array of chats, got ${regularResponse.data.runtimeType}', + ); + } + + if (pinnedResponse.data is! List) { + throw Exception( + 'Expected array of pinned chats, got ${pinnedResponse.data.runtimeType}', + ); + } + + if (archivedResponse.data is! List) { + throw Exception( + 'Expected array of archived chats, got ${archivedResponse.data.runtimeType}', + ); + } + + final regularChatList = regularResponse.data as List; + final pinnedChatList = pinnedResponse.data as List; + final archivedChatList = archivedResponse.data as List; + + debugPrint('DEBUG: Found ${regularChatList.length} regular chats'); + debugPrint('DEBUG: Found ${pinnedChatList.length} pinned chats'); + debugPrint('DEBUG: Found ${archivedChatList.length} archived chats'); + + // Convert OpenWebUI chat format to our Conversation format + final conversations = []; + final pinnedIds = {}; + final archivedIds = {}; + + // Process pinned conversations first + for (final chatData in pinnedChatList) { + try { + final conversation = _parseOpenWebUIChat(chatData); + // Create a new conversation instance with pinned=true + final pinnedConversation = conversation.copyWith(pinned: true); + conversations.add(pinnedConversation); + pinnedIds.add(conversation.id); + } catch (e) { + debugPrint('DEBUG: Error parsing pinned chat ${chatData['id']}: $e'); + } + } + + // Process archived conversations + for (final chatData in archivedChatList) { + try { + final conversation = _parseOpenWebUIChat(chatData); + // Create a new conversation instance with archived=true + final archivedConversation = conversation.copyWith(archived: true); + conversations.add(archivedConversation); + archivedIds.add(conversation.id); + } catch (e) { + debugPrint('DEBUG: Error parsing archived chat ${chatData['id']}: $e'); + } + } + + // Process regular conversations (excluding pinned and archived ones) + for (final chatData in regularChatList) { + try { + final conversation = _parseOpenWebUIChat(chatData); + // Only add if not already added as pinned or archived + if (!pinnedIds.contains(conversation.id) && + !archivedIds.contains(conversation.id)) { + conversations.add(conversation); + } + } catch (e) { + debugPrint('DEBUG: Error parsing chat ${chatData['id']}: $e'); + // Continue with other chats even if one fails + } + } + + debugPrint( + 'DEBUG: Successfully parsed ${conversations.length} conversations (${pinnedIds.length} pinned, ${archivedIds.length} archived)', + ); + return conversations; + } + + // Helper method to safely parse timestamps + DateTime _parseTimestamp(dynamic timestamp) { + if (timestamp == null) return DateTime.now(); + + if (timestamp is int) { + // OpenWebUI uses Unix timestamps in seconds + // Check if it's already in milliseconds (13 digits) or seconds (10 digits) + final timestampMs = timestamp > 1000000000000 + ? timestamp + : timestamp * 1000; + return DateTime.fromMillisecondsSinceEpoch(timestampMs); + } + + if (timestamp is String) { + final parsed = int.tryParse(timestamp); + if (parsed != null) { + final timestampMs = parsed > 1000000000000 ? parsed : parsed * 1000; + return DateTime.fromMillisecondsSinceEpoch(timestampMs); + } + } + + return DateTime.now(); // Fallback to current time + } + + // Parse OpenWebUI chat format to our Conversation format + Conversation _parseOpenWebUIChat(Map chatData) { + // OpenWebUI ChatTitleIdResponse format: + // { + // "id": "string", + // "title": "string", + // "updated_at": integer (timestamp), + // "created_at": integer (timestamp), + // "pinned": boolean (optional), + // "archived": boolean (optional), + // "share_id": string (optional), + // "folder_id": string (optional) + // } + + final id = chatData['id'] as String; + final title = chatData['title'] as String; + + // Safely parse timestamps with validation + // Try both snake_case and camelCase field names + final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt']; + final createdAtRaw = chatData['created_at'] ?? chatData['createdAt']; + + final updatedAt = _parseTimestamp(updatedAtRaw); + final createdAt = _parseTimestamp(createdAtRaw); + + // Parse additional OpenWebUI fields + // The API response might not include these fields, so we need to handle them safely + final pinned = chatData['pinned'] as bool? ?? false; + final archived = chatData['archived'] as bool? ?? false; + final shareId = chatData['share_id'] as String?; + final folderId = chatData['folder_id'] as String?; + + debugPrint( + 'DEBUG: Parsed conversation $id: pinned=$pinned, archived=$archived', + ); + + // For the list endpoint, we don't get the full chat messages + // We'll need to fetch individual chats later if needed + return Conversation( + id: id, + title: title, + createdAt: createdAt, + updatedAt: updatedAt, + pinned: pinned, + archived: archived, + shareId: shareId, + folderId: folderId, + messages: [], // Empty for now, will be loaded when chat is opened + ); + } + + Future getConversation(String id) async { + debugPrint('DEBUG: Fetching individual chat: $id'); + final response = await _dio.get('/api/v1/chats/$id'); + + debugPrint('DEBUG: Chat response: ${response.data}'); + + // Parse OpenWebUI ChatResponse format + final chatData = response.data as Map; + return _parseFullOpenWebUIChat(chatData); + } + + // Parse full OpenWebUI chat with messages + Conversation _parseFullOpenWebUIChat(Map chatData) { + final id = chatData['id'] as String; + final title = chatData['title'] as String; + + // Safely parse timestamps with validation + final updatedAt = _parseTimestamp(chatData['updated_at']); + final createdAt = _parseTimestamp(chatData['created_at']); + + // Parse additional OpenWebUI fields + final pinned = chatData['pinned'] as bool? ?? false; + final archived = chatData['archived'] as bool? ?? false; + final shareId = chatData['share_id'] as String?; + final folderId = chatData['folder_id'] as String?; + + // Parse messages from the 'chat' object or top-level messages + final chatObject = chatData['chat'] as Map?; + final messages = []; + + // Try multiple locations for messages + List? messagesList; + Map? messagesMap; + + if (chatObject != null) { + // Check for messages in chat.history.messages (map format) + final history = chatObject['history'] as Map?; + if (history != null && history['messages'] != null) { + messagesMap = history['messages'] as Map; + debugPrint( + 'DEBUG: Found ${messagesMap.length} messages in chat.history.messages', + ); + } + + // Check for messages in chat.messages (list format) + if (chatObject['messages'] != null) { + messagesList = chatObject['messages'] as List; + debugPrint( + 'DEBUG: Found ${messagesList.length} messages in chat.messages', + ); + } + } else if (chatData['messages'] != null) { + messagesList = chatData['messages'] as List; + debugPrint( + 'DEBUG: Found ${messagesList.length} messages in top-level messages', + ); + } + + // Parse messages from map format (chat.history.messages) + if (messagesMap != null) { + for (final entry in messagesMap.entries) { + try { + final msgData = entry.value as Map; + msgData['id'] = entry.key; // Use the key as the message ID + debugPrint( + 'DEBUG: Parsing message from map: ${entry.key} - role: ${msgData['role']} - content length: ${msgData['content']?.toString().length ?? 0}', + ); + // Convert OpenWebUI message format to our ChatMessage format + final message = _parseOpenWebUIMessage(msgData); + messages.add(message); + debugPrint( + 'DEBUG: Successfully parsed message from map: ${message.id} - ${message.role}', + ); + } catch (e) { + debugPrint('DEBUG: Error parsing message from map: $e'); + } + } + } + + // Parse messages from list format (chat.messages or top-level) + if (messagesList != null) { + for (final msgData in messagesList) { + try { + debugPrint( + 'DEBUG: Parsing message from list: ${msgData['id']} - role: ${msgData['role']} - content length: ${msgData['content']?.toString().length ?? 0}', + ); + // Convert OpenWebUI message format to our ChatMessage format + final message = _parseOpenWebUIMessage(msgData); + messages.add(message); + debugPrint( + 'DEBUG: Successfully parsed message from list: ${message.id} - ${message.role}', + ); + } catch (e) { + debugPrint('DEBUG: Error parsing message from list: $e'); + } + } + } + + debugPrint('DEBUG: Total parsed messages: ${messages.length}'); + + return Conversation( + id: id, + title: title, + createdAt: createdAt, + updatedAt: updatedAt, + pinned: pinned, + archived: archived, + shareId: shareId, + folderId: folderId, + messages: messages, + ); + } + + // Parse OpenWebUI message format to our ChatMessage format + ChatMessage _parseOpenWebUIMessage(Map msgData) { + // OpenWebUI message format may vary, but typically: + // { "role": "user|assistant", "content": "text", ... } + + // Create a single UUID instance to reuse + const uuid = Uuid(); + + // Handle content that could be either String or List (for content arrays) + final content = msgData['content']; + String contentString; + if (content is List) { + // For content arrays, extract the text content + final textContent = content.firstWhere( + (item) => item is Map && item['type'] == 'text', + orElse: () => {'text': ''}, + ); + contentString = textContent['text'] as String? ?? ''; + } else { + contentString = content as String? ?? ''; + } + + // Determine role based on available fields + String role; + if (msgData['role'] != null) { + role = msgData['role'] as String; + } else if (msgData['model'] != null) { + // Messages with model field are typically assistant messages + role = 'assistant'; + } else { + // Default to user if no role or model + role = 'user'; + } + + return ChatMessage( + id: msgData['id']?.toString() ?? uuid.v4(), + role: role, + content: contentString, + timestamp: _parseTimestamp(msgData['timestamp']), + model: msgData['model'] as String?, + ); + } + + // Create new conversation using OpenWebUI API + // Create new conversation using OpenWebUI API + Future createConversation({ + required String title, + required List messages, + String? model, + String? systemPrompt, + }) async { + debugPrint('DEBUG: Creating new conversation on OpenWebUI server'); + debugPrint('DEBUG: Title: $title, Messages: ${messages.length}'); + + // Convert messages to the new format with proper structure + final Map messagesMap = {}; + String? currentId; + + for (final msg in messages) { + final messageId = msg.id; + messagesMap[messageId] = { + 'id': messageId, + 'parentId': null, + 'childrenIds': [], + 'role': msg.role, + 'content': msg.content, + if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) + 'files': msg.attachmentIds!.map((attachmentId) { + if (attachmentId.startsWith('data:')) { + // This is an image data URL + return {'type': 'image', 'url': attachmentId}; + } else { + // This is a server file ID + return { + 'type': 'file', + 'id': attachmentId, + 'url': '${serverConfig.url}/api/v1/files/$attachmentId', + }; + } + }).toList(), + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + 'models': model != null ? [model] : [], + }; + currentId = messageId; // Use the last message as current + } + + // Create the chat data structure matching the expected format + final chatData = { + 'chat': { + 'id': '', + 'title': title, + 'models': model != null ? [model] : [], + 'params': {}, + 'history': { + 'messages': messagesMap, + if (currentId != null) 'currentId': currentId, + }, + 'messages': messages + .map( + (msg) => { + 'id': msg.id, + 'parentId': null, + 'childrenIds': [], + 'role': msg.role, + 'content': msg.content, + if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) + 'files': msg.attachmentIds!.map((attachmentId) { + if (attachmentId.startsWith('data:')) { + // This is an image data URL + return {'type': 'image', 'url': attachmentId}; + } else { + // This is a server file ID + return { + 'type': 'file', + 'id': attachmentId, + 'url': '${serverConfig.url}/api/v1/files/$attachmentId', + }; + } + }).toList(), + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + 'models': model != null ? [model] : [], + }, + ) + .toList(), + 'tags': [], + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }, + 'folder_id': null, + }; + + debugPrint('DEBUG: Sending chat data: $chatData'); + + final response = await _dio.post('/api/v1/chats/new', data: chatData); + + debugPrint('DEBUG: Create conversation response: ${response.data}'); + + // Parse the response + final responseData = response.data as Map; + return _parseFullOpenWebUIChat(responseData); + } + + // Update conversation with full chat data including all messages + Future updateConversationWithMessages( + String conversationId, + List messages, { + String? title, + String? model, + String? systemPrompt, + }) async { + debugPrint( + 'DEBUG: Updating conversation $conversationId with ${messages.length} messages', + ); + + // Convert messages to OpenWebUI format + final openWebUIMessages = messages + .map( + (msg) => { + 'role': msg.role, + 'content': msg.content, + if (msg.model != null) 'model': msg.model, + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + }, + ) + .toList(); + + // Create the chat data structure + final chatData = { + 'chat': { + 'messages': openWebUIMessages, + if (title != null) 'title': title, + if (model != null) 'model': model, + if (systemPrompt != null) 'system': systemPrompt, + }, + }; + + debugPrint('DEBUG: Updating chat with data: $chatData'); + + final response = await _dio.post( + '/api/v1/chats/$conversationId', + data: chatData, + ); + + debugPrint('DEBUG: Update conversation response: ${response.data}'); + } + + Future updateConversation( + String id, { + String? title, + String? systemPrompt, + }) async { + await _dio.put( + '/api/v1/chats/$id', + data: { + if (title != null) 'title': title, + if (systemPrompt != null) 'system': systemPrompt, + }, + ); + } + + Future deleteConversation(String id) async { + await _dio.delete('/api/v1/chats/$id'); + } + + // Pin/Unpin conversation + Future pinConversation(String id, bool pinned) async { + debugPrint('DEBUG: ${pinned ? 'Pinning' : 'Unpinning'} conversation: $id'); + await _dio.post('/api/v1/chats/$id/pin', data: {'pinned': pinned}); + } + + // Archive/Unarchive conversation + Future archiveConversation(String id, bool archived) async { + debugPrint( + 'DEBUG: ${archived ? 'Archiving' : 'Unarchiving'} conversation: $id', + ); + await _dio.post('/api/v1/chats/$id/archive', data: {'archived': archived}); + } + + // Share conversation + Future shareConversation(String id) async { + debugPrint('DEBUG: Sharing conversation: $id'); + final response = await _dio.post('/api/v1/chats/$id/share'); + final data = response.data as Map; + return data['share_id'] as String?; + } + + // Clone conversation + Future cloneConversation(String id) async { + debugPrint('DEBUG: Cloning conversation: $id'); + final response = await _dio.post('/api/v1/chats/$id/clone'); + return _parseFullOpenWebUIChat(response.data as Map); + } + + // User Settings + Future> getUserSettings() async { + debugPrint('DEBUG: Fetching user settings'); + final response = await _dio.get('/api/v1/users/user/settings'); + return response.data as Map; + } + + Future updateUserSettings(Map settings) async { + debugPrint('DEBUG: Updating user settings'); + await _dio.post('/api/v1/users/user/settings', data: settings); + } + + // Server Banners + Future>> getBanners() async { + debugPrint('DEBUG: Fetching server banners'); + final response = await _dio.get('/api/v1/configs/banners'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + // Suggestions + Future> getSuggestions() async { + debugPrint('DEBUG: Fetching conversation suggestions'); + final response = await _dio.get('/api/v1/configs/suggestions'); + final data = response.data; + if (data is List) { + return data.cast(); + } + return []; + } + + // Tools - Check available tools on server + Future>> getAvailableTools() async { + debugPrint('DEBUG: Fetching available tools'); + try { + final response = await _dio.get('/api/v1/tools/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + } catch (e) { + debugPrint('DEBUG: Error fetching tools: $e'); + } + return []; + } + + // Folders + Future>> getFolders() async { + debugPrint('DEBUG: Fetching folders'); + final response = await _dio.get('/api/v1/folders/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> createFolder({ + required String name, + String? parentId, + }) async { + debugPrint('DEBUG: Creating folder: $name'); + final response = await _dio.post( + '/api/v1/folders/', + data: {'name': name, if (parentId != null) 'parent_id': parentId}, + ); + return response.data as Map; + } + + Future updateFolder(String id, {String? name, String? parentId}) async { + debugPrint('DEBUG: Updating folder: $id'); + await _dio.put( + '/api/v1/folders/$id', + data: { + if (name != null) 'name': name, + if (parentId != null) 'parent_id': parentId, + }, + ); + } + + Future deleteFolder(String id) async { + debugPrint('DEBUG: Deleting folder: $id'); + await _dio.delete('/api/v1/folders/$id'); + } + + Future moveConversationToFolder( + String conversationId, + String? folderId, + ) async { + debugPrint( + 'DEBUG: Moving conversation $conversationId to folder $folderId', + ); + await _dio.post( + '/api/v1/chats/$conversationId/folder', + data: {'folder_id': folderId}, + ); + } + + Future> getConversationsInFolder(String folderId) async { + debugPrint('DEBUG: Fetching conversations in folder: $folderId'); + final response = await _dio.get('/api/v1/chats/folder/$folderId'); + final data = response.data; + if (data is List) { + return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + } + return []; + } + + // Tags + Future> getConversationTags(String conversationId) async { + debugPrint('DEBUG: Fetching tags for conversation: $conversationId'); + final response = await _dio.get('/api/v1/chats/$conversationId/tags'); + final data = response.data; + if (data is List) { + return data.cast(); + } + return []; + } + + Future addTagToConversation(String conversationId, String tag) async { + debugPrint('DEBUG: Adding tag "$tag" to conversation: $conversationId'); + await _dio.post('/api/v1/chats/$conversationId/tags', data: {'tag': tag}); + } + + Future removeTagFromConversation( + String conversationId, + String tag, + ) async { + debugPrint('DEBUG: Removing tag "$tag" from conversation: $conversationId'); + await _dio.delete('/api/v1/chats/$conversationId/tags/$tag'); + } + + Future> getAllTags() async { + debugPrint('DEBUG: Fetching all available tags'); + final response = await _dio.get('/api/v1/chats/tags'); + final data = response.data; + if (data is List) { + return data.cast(); + } + return []; + } + + Future> getConversationsByTag(String tag) async { + debugPrint('DEBUG: Fetching conversations with tag: $tag'); + final response = await _dio.get('/api/v1/chats/tags/$tag'); + final data = response.data; + if (data is List) { + return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + } + return []; + } + + // Files + Future getFileContent(String fileId) async { + debugPrint('DEBUG: Fetching file content: $fileId'); + final response = await _dio.get('/api/v1/files/$fileId/content'); + return response.data as String; + } + + Future> getFileInfo(String fileId) async { + debugPrint('DEBUG: Fetching file info: $fileId'); + final response = await _dio.get('/api/v1/files/$fileId'); + return response.data as Map; + } + + Future>> getUserFiles() async { + debugPrint('DEBUG: Fetching user files'); + final response = await _dio.get('/api/v1/files/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + // Enhanced File Operations + Future>> searchFiles({ + String? query, + String? contentType, + int? limit, + int? offset, + }) async { + debugPrint('DEBUG: Searching files with query: $query'); + final queryParams = {}; + if (query != null) queryParams['q'] = query; + if (contentType != null) queryParams['content_type'] = contentType; + if (limit != null) queryParams['limit'] = limit; + if (offset != null) queryParams['offset'] = offset; + + final response = await _dio.get( + '/api/v1/files/search', + queryParameters: queryParams, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future>> getAllFiles() async { + debugPrint('DEBUG: Fetching all files (admin)'); + final response = await _dio.get('/api/v1/files/all'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future uploadFileWithProgress( + String filePath, + String fileName, { + Function(int sent, int total)? onProgress, + }) async { + debugPrint('DEBUG: Uploading file with progress: $fileName'); + + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath, filename: fileName), + }); + + final response = await _dio.post( + '/api/v1/files/', + data: formData, + onSendProgress: onProgress, + ); + + return response.data['id'] as String; + } + + Future> updateFileContent( + String fileId, + String content, + ) async { + debugPrint('DEBUG: Updating file content: $fileId'); + final response = await _dio.post( + '/api/v1/files/$fileId/data/content/update', + data: {'content': content}, + ); + return response.data as Map; + } + + Future getFileHtmlContent(String fileId) async { + debugPrint('DEBUG: Fetching file HTML content: $fileId'); + final response = await _dio.get('/api/v1/files/$fileId/content/html'); + return response.data as String; + } + + Future deleteFile(String fileId) async { + debugPrint('DEBUG: Deleting file: $fileId'); + await _dio.delete('/api/v1/files/$fileId'); + } + + Future> updateFileMetadata( + String fileId, { + String? filename, + Map? metadata, + }) async { + debugPrint('DEBUG: Updating file metadata: $fileId'); + final response = await _dio.put( + '/api/v1/files/$fileId/metadata', + data: { + if (filename != null) 'filename': filename, + if (metadata != null) 'metadata': metadata, + }, + ); + return response.data as Map; + } + + Future>> processFilesBatch( + List fileIds, { + String? operation, + Map? options, + }) async { + debugPrint('DEBUG: Processing files batch: ${fileIds.length} files'); + final response = await _dio.post( + '/api/v1/retrieval/process/files/batch', + data: { + 'file_ids': fileIds, + if (operation != null) 'operation': operation, + if (options != null) 'options': options, + }, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future>> getFilesByType(String contentType) async { + debugPrint('DEBUG: Fetching files by type: $contentType'); + final response = await _dio.get( + '/api/v1/files/', + queryParameters: {'content_type': contentType}, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> getFileStats() async { + debugPrint('DEBUG: Fetching file statistics'); + final response = await _dio.get('/api/v1/files/stats'); + return response.data as Map; + } + + // Knowledge Base + Future>> getKnowledgeBases() async { + debugPrint('DEBUG: Fetching knowledge bases'); + final response = await _dio.get('/api/v1/knowledge/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> createKnowledgeBase({ + required String name, + String? description, + }) async { + debugPrint('DEBUG: Creating knowledge base: $name'); + final response = await _dio.post( + '/api/v1/knowledge/', + data: {'name': name, if (description != null) 'description': description}, + ); + return response.data as Map; + } + + Future updateKnowledgeBase( + String id, { + String? name, + String? description, + }) async { + debugPrint('DEBUG: Updating knowledge base: $id'); + await _dio.put( + '/api/v1/knowledge/$id', + data: { + if (name != null) 'name': name, + if (description != null) 'description': description, + }, + ); + } + + Future deleteKnowledgeBase(String id) async { + debugPrint('DEBUG: Deleting knowledge base: $id'); + await _dio.delete('/api/v1/knowledge/$id'); + } + + Future>> getKnowledgeBaseItems( + String knowledgeBaseId, + ) async { + debugPrint('DEBUG: Fetching knowledge base items: $knowledgeBaseId'); + final response = await _dio.get('/api/v1/knowledge/$knowledgeBaseId/items'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> addKnowledgeBaseItem( + String knowledgeBaseId, { + required String content, + String? title, + Map? metadata, + }) async { + debugPrint('DEBUG: Adding item to knowledge base: $knowledgeBaseId'); + final response = await _dio.post( + '/api/v1/knowledge/$knowledgeBaseId/items', + data: { + 'content': content, + if (title != null) 'title': title, + if (metadata != null) 'metadata': metadata, + }, + ); + return response.data as Map; + } + + Future>> searchKnowledgeBase( + String knowledgeBaseId, + String query, + ) async { + debugPrint('DEBUG: Searching knowledge base: $knowledgeBaseId for: $query'); + final response = await _dio.post( + '/api/v1/knowledge/$knowledgeBaseId/search', + data: {'query': query}, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + // Web Search + Future> performWebSearch(List queries) async { + debugPrint('DEBUG: Performing web search for queries: $queries'); + try { + final response = await _dio.post( + '/api/v1/retrieval/process/web/search', + data: {'queries': queries}, + ); + + debugPrint('DEBUG: Web search response status: ${response.statusCode}'); + debugPrint( + 'DEBUG: Web search response type: ${response.data.runtimeType}', + ); + debugPrint('DEBUG: Web search response data: ${response.data}'); + + return response.data as Map; + } catch (e) { + debugPrint('DEBUG: Web search API error: $e'); + if (e is DioException) { + debugPrint('DEBUG: Web search error response: ${e.response?.data}'); + debugPrint('DEBUG: Web search error status: ${e.response?.statusCode}'); + } + rethrow; + } + } + + // Get detailed model information + Future?> getModelDetails(String modelId) async { + try { + final response = await _dio.get( + '/api/v1/models/model', + queryParameters: {'id': modelId}, + ); + + if (response.statusCode == 200 && response.data != null) { + final modelData = response.data as Map; + debugPrint('DEBUG: Model details for $modelId: $modelData'); + return modelData; + } + } catch (e) { + debugPrint('DEBUG: Failed to get model details for $modelId: $e'); + } + return null; + } + + // Generate title for conversation using dedicated endpoint + Future generateTitle({ + required String conversationId, + required List> messages, + required String model, + }) async { + try { + debugPrint('DEBUG: Generating title for conversation: $conversationId'); + + final response = await _dio.post( + '/api/v1/tasks/title/completions', + data: {'chat_id': conversationId, 'messages': messages, 'model': model}, + ); + + if (response.statusCode == 200 && response.data != null) { + debugPrint('DEBUG: Raw title response: ${response.data}'); + + // Parse the complex response structure + String? extractedTitle; + + try { + final responseData = response.data as Map; + + // Check if there's a direct title field + if (responseData.containsKey('title')) { + extractedTitle = responseData['title']?.toString(); + } + // Check if it's in choices format (OpenAI-style response) + else if (responseData.containsKey('choices') && + responseData['choices'] is List) { + final choices = responseData['choices'] as List; + if (choices.isNotEmpty) { + final firstChoice = choices[0] as Map; + if (firstChoice.containsKey('message')) { + final message = firstChoice['message'] as Map; + final content = message['content']?.toString() ?? ''; + + // Extract title from JSON-formatted content + if (content.contains('```json') && content.contains('```')) { + // Extract JSON from markdown code block + final jsonStart = content.indexOf('```json') + 7; + final jsonEnd = content.lastIndexOf('```'); + if (jsonEnd > jsonStart) { + final jsonString = content + .substring(jsonStart, jsonEnd) + .trim(); + try { + final jsonData = + jsonDecode(jsonString) as Map; + extractedTitle = jsonData['title']?.toString(); + } catch (e) { + debugPrint( + 'DEBUG: Failed to parse JSON from title response: $e', + ); + } + } + } else { + // Try to parse the content directly as JSON + try { + final jsonData = + jsonDecode(content) as Map; + extractedTitle = jsonData['title']?.toString(); + } catch (e) { + // If not JSON, use content as-is + extractedTitle = content; + } + } + } + } + } + + // Clean up the extracted title + if (extractedTitle != null && extractedTitle.isNotEmpty) { + // Remove any remaining markdown formatting + extractedTitle = extractedTitle + .replaceAll(RegExp(r'```.*?```', dotAll: true), '') + .trim(); + extractedTitle = extractedTitle + .replaceAll(RegExp(r'^[{"]|["}]$'), '') + .trim(); + + // Ensure it's not just "New Chat" or empty + if (extractedTitle.isNotEmpty && extractedTitle != 'New Chat') { + debugPrint( + 'DEBUG: Successfully extracted title: $extractedTitle', + ); + return extractedTitle; + } + } + } catch (e) { + debugPrint('DEBUG: Error parsing title response: $e'); + } + + debugPrint('DEBUG: Could not extract valid title from response'); + } + } catch (e) { + debugPrint('DEBUG: Failed to generate title: $e'); + } + return null; + } + + // Send chat completed notification + Future sendChatCompleted({ + required String chatId, + required String messageId, + required List> messages, + required String model, + Map? modelItem, + String? sessionId, + }) async { + debugPrint('DEBUG: Sending chat completed for message: $messageId'); + + final requestData = { + 'model': model, + 'messages': messages, + if (modelItem != null) 'model_item': modelItem, + 'chat_id': chatId, + if (sessionId != null) 'session_id': sessionId, + 'id': messageId, + }; + + debugPrint('DEBUG: Chat completed request data: $requestData'); + + try { + final response = await _dio.post( + '/api/chat/completed', + data: requestData, + ); + debugPrint('DEBUG: Chat completed response: ${response.statusCode}'); + } catch (e) { + debugPrint('DEBUG: Chat completed error: $e'); + // Non-critical error, don't throw + } + } + + // Query a collection for content + Future> queryCollection( + String collectionName, + String query, + ) async { + debugPrint( + 'DEBUG: Querying collection: $collectionName with query: $query', + ); + try { + final response = await _dio.post( + '/api/v1/retrieval/query/collection', + data: { + 'collection_names': [collectionName], // API expects an array + 'query': query, + 'k': 5, // Limit to top 5 results + }, + ); + + debugPrint( + 'DEBUG: Collection query response status: ${response.statusCode}', + ); + debugPrint( + 'DEBUG: Collection query response type: ${response.data.runtimeType}', + ); + debugPrint('DEBUG: Collection query response data: ${response.data}'); + + if (response.data is List) { + return response.data as List; + } else if (response.data is Map) { + // If the response is a map, check for common result keys + final data = response.data as Map; + if (data.containsKey('results')) { + return data['results'] as List? ?? []; + } else if (data.containsKey('documents')) { + return data['documents'] as List? ?? []; + } else if (data.containsKey('data')) { + return data['data'] as List? ?? []; + } + } + + return []; + } catch (e) { + debugPrint('DEBUG: Collection query API error: $e'); + if (e is DioException) { + debugPrint( + 'DEBUG: Collection query error response: ${e.response?.data}', + ); + debugPrint( + 'DEBUG: Collection query error status: ${e.response?.statusCode}', + ); + } + rethrow; + } + } + + // Get retrieval configuration to check web search settings + Future> getRetrievalConfig() async { + debugPrint('DEBUG: Getting retrieval configuration'); + try { + final response = await _dio.get('/api/v1/retrieval/config'); + + debugPrint( + 'DEBUG: Retrieval config response status: ${response.statusCode}', + ); + debugPrint('DEBUG: Retrieval config response data: ${response.data}'); + + return response.data as Map; + } catch (e) { + debugPrint('DEBUG: Retrieval config API error: $e'); + if (e is DioException) { + debugPrint( + 'DEBUG: Retrieval config error response: ${e.response?.data}', + ); + debugPrint( + 'DEBUG: Retrieval config error status: ${e.response?.statusCode}', + ); + } + rethrow; + } + } + + // Audio + Future> getAvailableVoices() async { + debugPrint('DEBUG: Fetching available voices'); + final response = await _dio.get('/api/v1/audio/voices'); + final data = response.data; + if (data is List) { + return data.cast(); + } + return []; + } + + Future> generateSpeech({ + required String text, + String? voice, + }) async { + debugPrint( + 'DEBUG: Generating speech for text: ${text.substring(0, 50)}...', + ); + final response = await _dio.post( + '/api/v1/audio/speech', + data: {'text': text, if (voice != null) 'voice': voice}, + ); + + // Return audio data as bytes + if (response.data is List) { + return (response.data as List).cast(); + } + return []; + } + + Future transcribeAudio( + List audioData, { + String? language, + }) async { + // Normalize language to primary ISO 639-1 (e.g., en-US -> en) per server accepted list + String? normalizedLang; + if (language != null && language.isNotEmpty) { + normalizedLang = language.split(RegExp('[-_]')).first.toLowerCase(); + } + + debugPrint( + 'DEBUG: Transcribing audio data: bytes=${audioData.length}, language=${normalizedLang ?? 'null'}', + ); + + FormData buildForm(String? lang) { + final Map formMap = { + 'file': MultipartFile.fromBytes( + audioData, + filename: 'audio.wav', + contentType: MediaType.parse('audio/wav'), + ), + }; + if (lang != null && lang.isNotEmpty) { + formMap['language'] = lang; + } + return FormData.fromMap(formMap); + } + + var formData = buildForm(normalizedLang); + try { + final response = await _dio.post( + '/api/v1/audio/transcriptions', + data: formData, + options: Options(headers: {'Accept': 'application/json'}), + ); + final data = response.data; + debugPrint( + 'DEBUG: Transcription response status: ${response.statusCode}', + ); + debugPrint('DEBUG: Transcription response data: $data'); + if (data is String) return data; + if (data is Map) { + final text = data['text'] ?? data['transcription'] ?? data['result']; + if (text is String) return text; + if (data['data'] is Map && (data['data']['text'] is String)) { + return data['data']['text'] as String; + } + } + return ''; + } catch (e) { + debugPrint('DEBUG: Transcription API error: $e'); + // If server complains about invalid language code, retry without language + try { + if (e is DioException) { + final data = e.response?.data; + final msg = data is Map + ? data.toString() + : data?.toString() ?? ''; + if (msg.contains("not a valid language code")) { + debugPrint('DEBUG: Retrying transcription without language'); + final retryResponse = await _dio.post( + '/api/v1/audio/transcriptions', + data: buildForm(null), + options: Options(headers: {'Accept': 'application/json'}), + ); + final rdata = retryResponse.data; + debugPrint( + 'DEBUG: Transcription retry status: ${retryResponse.statusCode}', + ); + debugPrint('DEBUG: Transcription retry data: $rdata'); + if (rdata is String) return rdata; + if (rdata is Map) { + final text = + rdata['text'] ?? rdata['transcription'] ?? rdata['result']; + if (text is String) return text; + if (rdata['data'] is Map && (rdata['data']['text'] is String)) { + return rdata['data']['text'] as String; + } + } + return ''; + } + } + } catch (e2) { + debugPrint('DEBUG: Transcription retry error: $e2'); + } + rethrow; + } + } + + // Image Generation + Future>> getImageModels() async { + debugPrint('DEBUG: Fetching image generation models'); + final response = await _dio.get('/api/v1/images/models'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> generateImage({ + required String prompt, + String? model, + int? width, + int? height, + int? steps, + double? guidance, + }) async { + debugPrint( + 'DEBUG: Generating image with prompt: ${prompt.substring(0, 50)}...', + ); + final response = await _dio.post( + '/api/v1/images/generations', + data: { + 'prompt': prompt, + if (model != null) 'model': model, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (steps != null) 'steps': steps, + if (guidance != null) 'guidance': guidance, + }, + ); + return response.data as Map; + } + + // Prompts + Future>> getPrompts() async { + debugPrint('DEBUG: Fetching prompts'); + final response = await _dio.get('/api/v1/prompts/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> createPrompt({ + required String title, + required String content, + String? description, + List? tags, + }) async { + debugPrint('DEBUG: Creating prompt: $title'); + final response = await _dio.post( + '/api/v1/prompts/', + data: { + 'title': title, + 'content': content, + if (description != null) 'description': description, + if (tags != null) 'tags': tags, + }, + ); + return response.data as Map; + } + + Future updatePrompt( + String id, { + String? title, + String? content, + String? description, + List? tags, + }) async { + debugPrint('DEBUG: Updating prompt: $id'); + await _dio.put( + '/api/v1/prompts/$id', + data: { + if (title != null) 'title': title, + if (content != null) 'content': content, + if (description != null) 'description': description, + if (tags != null) 'tags': tags, + }, + ); + } + + Future deletePrompt(String id) async { + debugPrint('DEBUG: Deleting prompt: $id'); + await _dio.delete('/api/v1/prompts/$id'); + } + + // Tools & Functions + Future>> getTools() async { + debugPrint('DEBUG: Fetching tools'); + final response = await _dio.get('/api/v1/tools/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future>> getFunctions() async { + debugPrint('DEBUG: Fetching functions'); + final response = await _dio.get('/api/v1/functions/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> createTool({ + required String name, + required Map spec, + }) async { + debugPrint('DEBUG: Creating tool: $name'); + final response = await _dio.post( + '/api/v1/tools/', + data: {'name': name, 'spec': spec}, + ); + return response.data as Map; + } + + Future> createFunction({ + required String name, + required String code, + String? description, + }) async { + debugPrint('DEBUG: Creating function: $name'); + final response = await _dio.post( + '/api/v1/functions/', + data: { + 'name': name, + 'code': code, + if (description != null) 'description': description, + }, + ); + return response.data as Map; + } + + // Enhanced Tools Management Operations + Future> getTool(String toolId) async { + debugPrint('DEBUG: Fetching tool details: $toolId'); + final response = await _dio.get('/api/v1/tools/id/$toolId'); + return response.data as Map; + } + + Future> updateTool( + String toolId, { + String? name, + Map? spec, + String? description, + }) async { + debugPrint('DEBUG: Updating tool: $toolId'); + final response = await _dio.post( + '/api/v1/tools/id/$toolId/update', + data: { + if (name != null) 'name': name, + if (spec != null) 'spec': spec, + if (description != null) 'description': description, + }, + ); + return response.data as Map; + } + + Future deleteTool(String toolId) async { + debugPrint('DEBUG: Deleting tool: $toolId'); + await _dio.delete('/api/v1/tools/id/$toolId/delete'); + } + + Future> getToolValves(String toolId) async { + debugPrint('DEBUG: Fetching tool valves: $toolId'); + final response = await _dio.get('/api/v1/tools/id/$toolId/valves'); + return response.data as Map; + } + + Future> updateToolValves( + String toolId, + Map valves, + ) async { + debugPrint('DEBUG: Updating tool valves: $toolId'); + final response = await _dio.post( + '/api/v1/tools/id/$toolId/valves/update', + data: valves, + ); + return response.data as Map; + } + + Future> getUserToolValves(String toolId) async { + debugPrint('DEBUG: Fetching user tool valves: $toolId'); + final response = await _dio.get('/api/v1/tools/id/$toolId/valves/user'); + return response.data as Map; + } + + Future> updateUserToolValves( + String toolId, + Map valves, + ) async { + debugPrint('DEBUG: Updating user tool valves: $toolId'); + final response = await _dio.post( + '/api/v1/tools/id/$toolId/valves/user/update', + data: valves, + ); + return response.data as Map; + } + + Future>> exportTools() async { + debugPrint('DEBUG: Exporting tools configuration'); + final response = await _dio.get('/api/v1/tools/export'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> loadToolFromUrl(String url) async { + debugPrint('DEBUG: Loading tool from URL: $url'); + final response = await _dio.post( + '/api/v1/tools/load/url', + data: {'url': url}, + ); + return response.data as Map; + } + + // Enhanced Functions Management Operations + Future> getFunction(String functionId) async { + debugPrint('DEBUG: Fetching function details: $functionId'); + final response = await _dio.get('/api/v1/functions/id/$functionId'); + return response.data as Map; + } + + Future> updateFunction( + String functionId, { + String? name, + String? code, + String? description, + }) async { + debugPrint('DEBUG: Updating function: $functionId'); + final response = await _dio.post( + '/api/v1/functions/id/$functionId/update', + data: { + if (name != null) 'name': name, + if (code != null) 'code': code, + if (description != null) 'description': description, + }, + ); + return response.data as Map; + } + + Future deleteFunction(String functionId) async { + debugPrint('DEBUG: Deleting function: $functionId'); + await _dio.delete('/api/v1/functions/id/$functionId/delete'); + } + + Future> toggleFunction(String functionId) async { + debugPrint('DEBUG: Toggling function: $functionId'); + final response = await _dio.post('/api/v1/functions/id/$functionId/toggle'); + return response.data as Map; + } + + Future> toggleGlobalFunction(String functionId) async { + debugPrint('DEBUG: Toggling global function: $functionId'); + final response = await _dio.post( + '/api/v1/functions/id/$functionId/toggle/global', + ); + return response.data as Map; + } + + Future> getFunctionValves(String functionId) async { + debugPrint('DEBUG: Fetching function valves: $functionId'); + final response = await _dio.get('/api/v1/functions/id/$functionId/valves'); + return response.data as Map; + } + + Future> updateFunctionValves( + String functionId, + Map valves, + ) async { + debugPrint('DEBUG: Updating function valves: $functionId'); + final response = await _dio.post( + '/api/v1/functions/id/$functionId/valves/update', + data: valves, + ); + return response.data as Map; + } + + Future> getUserFunctionValves(String functionId) async { + debugPrint('DEBUG: Fetching user function valves: $functionId'); + final response = await _dio.get( + '/api/v1/functions/id/$functionId/valves/user', + ); + return response.data as Map; + } + + Future> updateUserFunctionValves( + String functionId, + Map valves, + ) async { + debugPrint('DEBUG: Updating user function valves: $functionId'); + final response = await _dio.post( + '/api/v1/functions/id/$functionId/valves/user/update', + data: valves, + ); + return response.data as Map; + } + + Future> syncFunctions() async { + debugPrint('DEBUG: Syncing functions'); + final response = await _dio.post('/api/v1/functions/sync'); + return response.data as Map; + } + + Future>> exportFunctions() async { + debugPrint('DEBUG: Exporting functions configuration'); + final response = await _dio.get('/api/v1/functions/export'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + // Memory & Notes + Future>> getMemories() async { + debugPrint('DEBUG: Fetching memories'); + final response = await _dio.get('/api/v1/memories/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> createMemory({ + required String content, + String? title, + }) async { + debugPrint('DEBUG: Creating memory'); + final response = await _dio.post( + '/api/v1/memories/', + data: {'content': content, if (title != null) 'title': title}, + ); + return response.data as Map; + } + + Future>> getNotes() async { + debugPrint('DEBUG: Fetching notes'); + final response = await _dio.get('/api/v1/notes/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> createNote({ + required String title, + required String content, + List? tags, + }) async { + debugPrint('DEBUG: Creating note: $title'); + final response = await _dio.post( + '/api/v1/notes/', + data: { + 'title': title, + 'content': content, + if (tags != null) 'tags': tags, + }, + ); + return response.data as Map; + } + + Future updateNote( + String id, { + String? title, + String? content, + List? tags, + }) async { + debugPrint('DEBUG: Updating note: $id'); + await _dio.put( + '/api/v1/notes/$id', + data: { + if (title != null) 'title': title, + if (content != null) 'content': content, + if (tags != null) 'tags': tags, + }, + ); + } + + Future deleteNote(String id) async { + debugPrint('DEBUG: Deleting note: $id'); + await _dio.delete('/api/v1/notes/$id'); + } + + // Team Collaboration + Future>> getChannels() async { + debugPrint('DEBUG: Fetching channels'); + final response = await _dio.get('/api/v1/channels/'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> createChannel({ + required String name, + String? description, + bool isPrivate = false, + }) async { + debugPrint('DEBUG: Creating channel: $name'); + final response = await _dio.post( + '/api/v1/channels/', + data: { + 'name': name, + if (description != null) 'description': description, + 'is_private': isPrivate, + }, + ); + return response.data as Map; + } + + Future joinChannel(String channelId) async { + debugPrint('DEBUG: Joining channel: $channelId'); + await _dio.post('/api/v1/channels/$channelId/join'); + } + + Future leaveChannel(String channelId) async { + debugPrint('DEBUG: Leaving channel: $channelId'); + await _dio.post('/api/v1/channels/$channelId/leave'); + } + + Future>> getChannelMembers(String channelId) async { + debugPrint('DEBUG: Fetching channel members: $channelId'); + final response = await _dio.get('/api/v1/channels/$channelId/members'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> getChannelConversations(String channelId) async { + debugPrint('DEBUG: Fetching channel conversations: $channelId'); + final response = await _dio.get('/api/v1/channels/$channelId/chats'); + final data = response.data; + if (data is List) { + return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + } + return []; + } + + // Enhanced Channel Management Operations + Future> getChannel(String channelId) async { + debugPrint('DEBUG: Fetching channel details: $channelId'); + final response = await _dio.get('/api/v1/channels/$channelId'); + return response.data as Map; + } + + Future> updateChannel( + String channelId, { + String? name, + String? description, + bool? isPrivate, + }) async { + debugPrint('DEBUG: Updating channel: $channelId'); + final response = await _dio.post( + '/api/v1/channels/$channelId/update', + data: { + if (name != null) 'name': name, + if (description != null) 'description': description, + if (isPrivate != null) 'is_private': isPrivate, + }, + ); + return response.data as Map; + } + + Future deleteChannel(String channelId) async { + debugPrint('DEBUG: Deleting channel: $channelId'); + await _dio.delete('/api/v1/channels/$channelId/delete'); + } + + Future>> getChannelMessages( + String channelId, { + int? limit, + int? offset, + DateTime? before, + DateTime? after, + }) async { + debugPrint('DEBUG: Fetching channel messages: $channelId'); + final queryParams = {}; + if (limit != null) queryParams['limit'] = limit; + if (offset != null) queryParams['offset'] = offset; + if (before != null) queryParams['before'] = before.toIso8601String(); + if (after != null) queryParams['after'] = after.toIso8601String(); + + final response = await _dio.get( + '/api/v1/channels/$channelId/messages', + queryParameters: queryParams, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> postChannelMessage( + String channelId, { + required String content, + String? messageType, + Map? metadata, + }) async { + debugPrint('DEBUG: Posting message to channel: $channelId'); + final response = await _dio.post( + '/api/v1/channels/$channelId/messages/post', + data: { + 'content': content, + if (messageType != null) 'message_type': messageType, + if (metadata != null) 'metadata': metadata, + }, + ); + return response.data as Map; + } + + Future> updateChannelMessage( + String channelId, + String messageId, { + String? content, + Map? metadata, + }) async { + debugPrint('DEBUG: Updating channel message: $channelId/$messageId'); + final response = await _dio.post( + '/api/v1/channels/$channelId/messages/$messageId/update', + data: { + if (content != null) 'content': content, + if (metadata != null) 'metadata': metadata, + }, + ); + return response.data as Map; + } + + Future deleteChannelMessage(String channelId, String messageId) async { + debugPrint('DEBUG: Deleting channel message: $channelId/$messageId'); + await _dio.delete('/api/v1/channels/$channelId/messages/$messageId'); + } + + Future> addMessageReaction( + String channelId, + String messageId, + String emoji, + ) async { + debugPrint('DEBUG: Adding reaction to message: $channelId/$messageId'); + final response = await _dio.post( + '/api/v1/channels/$channelId/messages/$messageId/reactions', + data: {'emoji': emoji}, + ); + return response.data as Map; + } + + Future removeMessageReaction( + String channelId, + String messageId, + String emoji, + ) async { + debugPrint('DEBUG: Removing reaction from message: $channelId/$messageId'); + await _dio.delete( + '/api/v1/channels/$channelId/messages/$messageId/reactions/$emoji', + ); + } + + Future>> getMessageReactions( + String channelId, + String messageId, + ) async { + debugPrint('DEBUG: Fetching message reactions: $channelId/$messageId'); + final response = await _dio.get( + '/api/v1/channels/$channelId/messages/$messageId/reactions', + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future>> getMessageThread( + String channelId, + String messageId, + ) async { + debugPrint('DEBUG: Fetching message thread: $channelId/$messageId'); + final response = await _dio.get( + '/api/v1/channels/$channelId/messages/$messageId/thread', + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + Future> replyToMessage( + String channelId, + String messageId, { + required String content, + Map? metadata, + }) async { + debugPrint('DEBUG: Replying to message: $channelId/$messageId'); + final response = await _dio.post( + '/api/v1/channels/$channelId/messages/$messageId/reply', + data: {'content': content, if (metadata != null) 'metadata': metadata}, + ); + return response.data as Map; + } + + Future markChannelRead(String channelId, {String? messageId}) async { + debugPrint('DEBUG: Marking channel as read: $channelId'); + await _dio.post( + '/api/v1/channels/$channelId/read', + data: {if (messageId != null) 'last_read_message_id': messageId}, + ); + } + + Future> getChannelUnreadCount(String channelId) async { + debugPrint('DEBUG: Fetching unread count for channel: $channelId'); + final response = await _dio.get('/api/v1/channels/$channelId/unread'); + return response.data as Map; + } + + // Chat streaming with conversation context + String _getCurrentWeekday() { + final weekdays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + return weekdays[DateTime.now().weekday - 1]; + } + + // Returns a record with (stream, messageId, sessionId) + ({Stream stream, String messageId, String sessionId}) + sendMessageDirect({ + required List> messages, + required String model, + String? conversationId, + List>? tools, + bool enableWebSearch = false, + Map? modelItem, + }) { + final streamController = StreamController(); + + // Generate unique IDs + final messageId = const Uuid().v4(); + final sessionId = const Uuid().v4().substring(0, 20); + + // Check if this is a Gemini model that requires special handling + final isGeminiModel = model.toLowerCase().contains('gemini'); + debugPrint('DEBUG: Is Gemini model: $isGeminiModel'); + + // Process messages to match OpenWebUI format + final processedMessages = messages.map((message) { + final role = message['role'] as String; + final content = message['content']; + final files = message['files'] as List>?; + + final isContentArray = content is List; + final hasImages = files?.any((file) => file['type'] == 'image') ?? false; + + if (isContentArray) { + return {'role': role, 'content': content}; + } else if (hasImages && role == 'user') { + final imageFiles = files! + .where((file) => file['type'] == 'image') + .toList(); + final contentText = content is String ? content : ''; + final contentArray = >[ + {'type': 'text', 'text': contentText}, + ]; + + for (final file in imageFiles) { + contentArray.add({ + 'type': 'image_url', + 'image_url': {'url': file['url']}, + }); + } + return {'role': role, 'content': contentArray}; + } else { + final contentText = content is String ? content : ''; + return {'role': role, 'content': contentText}; + } + }).toList(); + + // Separate files from messages + final allFiles = >[]; + for (final message in messages) { + final files = message['files'] as List>?; + if (files != null) { + final nonImageFiles = files + .where((file) => file['type'] != 'image') + .toList(); + allFiles.addAll(nonImageFiles); + } + } + + // Build request data (exactly like OpenWebUI) + final data = { + 'model': model, + 'messages': processedMessages, + 'stream': true, // Always enable streaming + 'max_tokens': null, // Let the model decide + 'temperature': 0.8, + 'top_p': 0.9, + 'frequency_penalty': 0.0, + 'presence_penalty': 0.0, + if (conversationId != null) 'chat_id': conversationId, + if (tools != null && tools.isNotEmpty) 'tools': tools, + if (allFiles.isNotEmpty) 'files': allFiles, + if (enableWebSearch) 'web_search': enableWebSearch, + }; + + debugPrint('DEBUG: Starting SSE streaming request'); + debugPrint('DEBUG: Model: $model'); + debugPrint('DEBUG: Message count: ${processedMessages.length}'); + + // Use SSE streaming exactly like OpenWebUI frontend + _streamChatCompletion(data, streamController, messageId); + + return ( + stream: streamController.stream, + messageId: messageId, + sessionId: sessionId, + ); + } + + // SSE streaming implementation that matches OpenWebUI exactly + void _streamChatCompletion( + Map data, + StreamController streamController, + String messageId, + ) async { + try { + debugPrint('DEBUG: Making SSE request to /api/chat/completions'); + + // Make the request with proper SSE headers (exactly like OpenWebUI) + final response = await _dio.post( + '/api/chat/completions', + data: data, + options: Options( + responseType: ResponseType.stream, + headers: { + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + // Disable response timeout to allow streaming + receiveTimeout: null, + ), + ); + + debugPrint( + 'DEBUG: SSE response received, status: ${response.statusCode}', + ); + + if (response.statusCode != 200) { + throw Exception( + 'HTTP ${response.statusCode}: Failed to start streaming', + ); + } + + // Process the SSE stream exactly like OpenWebUI frontend + final stream = response.data.stream as Stream>; + String buffer = ''; + + await for (final chunk in stream) { + try { + // Decode chunk to string + final chunkStr = utf8.decode(chunk); + buffer += chunkStr; + + // Process complete lines (SSE format) + final lines = buffer.split('\n'); + buffer = lines.removeLast(); // Keep incomplete line in buffer + + for (final line in lines) { + final trimmedLine = line.trim(); + if (trimmedLine.isEmpty) continue; + + debugPrint('DEBUG: SSE line: $trimmedLine'); + + if (trimmedLine.startsWith('data: ')) { + final jsonStr = trimmedLine.substring(6); // Remove "data: " + + if (jsonStr == '[DONE]') { + debugPrint('DEBUG: SSE stream finished with [DONE]'); + streamController.close(); + return; + } + + try { + final json = jsonDecode(jsonStr) as Map; + debugPrint('DEBUG: SSE JSON: $json'); + + // Process exactly like OpenWebUI + if (json.containsKey('choices')) { + final choices = json['choices'] as List?; + if (choices != null && choices.isNotEmpty) { + final choice = choices[0] as Map; + + if (choice.containsKey('delta')) { + final delta = choice['delta'] as Map; + + // Handle content streaming (word by word) + if (delta.containsKey('content')) { + final content = delta['content'] as String?; + if (content != null && content.isNotEmpty) { + debugPrint('DEBUG: Adding content chunk: "$content"'); + streamController.add(content); + } + } + + // Handle function calls + if (delta.containsKey('tool_calls')) { + final toolCalls = delta['tool_calls'] as List?; + if (toolCalls != null && toolCalls.isNotEmpty) { + debugPrint('DEBUG: Tool calls received: $toolCalls'); + // Handle tool calls if needed + } + } + } + + // Handle finish reason + if (choice.containsKey('finish_reason')) { + final finishReason = choice['finish_reason']; + if (finishReason != null) { + debugPrint( + 'DEBUG: Stream finished with reason: $finishReason', + ); + streamController.close(); + return; + } + } + } + } else if (json.containsKey('error')) { + // Handle server errors + final error = json['error']; + debugPrint('DEBUG: SSE error: $error'); + streamController.addError('Server error: $error'); + return; + } else { + debugPrint('DEBUG: Unknown SSE JSON format: $json'); + } + } catch (e) { + debugPrint('DEBUG: Error parsing SSE JSON "$jsonStr": $e'); + // Continue processing other lines + } + } else if (trimmedLine.startsWith('event: ') || + trimmedLine.startsWith('id: ') || + trimmedLine.startsWith('retry: ')) { + // Handle other SSE fields (ignore for now) + debugPrint('DEBUG: SSE metadata: $trimmedLine'); + } else { + debugPrint('DEBUG: Unknown SSE line format: $trimmedLine'); + } + } + } catch (e) { + debugPrint('DEBUG: Error processing SSE chunk: $e'); + // Continue processing + } + } + + // Stream ended without [DONE] marker + debugPrint('DEBUG: SSE stream ended unexpectedly'); + streamController.close(); + } catch (e) { + debugPrint('DEBUG: SSE streaming error: $e'); + streamController.addError(e); + } + } + + // Initialize Socket.IO connection + Future _initializeSocket() async { + if (_socket != null && _socket!.connected) { + return; // Already connected + } + + try { + debugPrint( + 'DEBUG: Initializing Socket.IO connection to ${serverConfig.url}', + ); + + _socket = io.io( + serverConfig.url, + io.OptionBuilder() + .setTransports(['websocket', 'polling']) + .enableReconnection() + .setReconnectionDelay(1000) + .setReconnectionDelayMax(5000) + .setPath('/ws/socket.io') + .setAuth({'token': _authInterceptor.authToken}) + .build(), + ); + + _socket!.onConnect((_) { + debugPrint('DEBUG: Socket.IO connected with ID: ${_socket!.id}'); + + // Emit user-join event with auth token + _socket!.emit('user-join', { + 'auth': {'token': _authInterceptor.authToken}, + }); + }); + + _socket!.onDisconnect((_) { + debugPrint('DEBUG: Socket.IO disconnected'); + }); + + _socket!.onError((error) { + debugPrint('DEBUG: Socket.IO error: $error'); + }); + + _socket!.onReconnect((_) { + debugPrint('DEBUG: Socket.IO reconnected'); + }); + } catch (e) { + debugPrint('DEBUG: Failed to initialize Socket.IO: $e'); + } + } + + // Socket.IO streaming method that listens to real-time events + ({Stream stream, String messageId, String sessionId}) + sendMessageWithSocketIO({ + required List> messages, + required String model, + String? conversationId, + List>? tools, + bool enableWebSearch = false, + Map? modelItem, + }) { + final streamController = StreamController(); + + // Generate unique IDs + final messageId = const Uuid().v4(); + final sessionId = const Uuid().v4().substring(0, 20); + + debugPrint('DEBUG: Starting Socket.IO streaming for message: $messageId'); + + // Initialize socket connection + _initializeSocket() + .then((_) { + _handleSocketIOStreamingResponse(messageId, streamController); + + // Send the chat completion request via API + // This will trigger the server to emit Socket.IO events + _sendChatCompletionForSocketIO( + messages: messages, + model: model, + conversationId: conversationId, + messageId: messageId, + tools: tools, + enableWebSearch: enableWebSearch, + modelItem: modelItem, + ); + }) + .catchError((error) { + debugPrint('DEBUG: Socket.IO initialization failed: $error'); + streamController.addError('Failed to initialize Socket.IO: $error'); + }); + + return ( + stream: streamController.stream, + messageId: messageId, + sessionId: sessionId, + ); + } + + // Handle Socket.IO real-time streaming events + void _handleSocketIOStreamingResponse( + String messageId, + StreamController streamController, + ) async { + // Check if socket is available + if (_socket == null || !_socket!.connected) { + debugPrint( + 'DEBUG: Socket not available for real-time streaming, falling back to polling', + ); + streamController.addError('Socket.IO not connected'); + streamController.close(); + return; + } + + debugPrint( + 'DEBUG: Setting up Socket.IO real-time streaming for message: $messageId', + ); + bool streamCompleted = false; + Timer? timeoutTimer; + + // Set up timeout to prevent hanging + timeoutTimer = Timer(const Duration(seconds: 30), () { + if (!streamCompleted) { + debugPrint( + 'DEBUG: Socket.IO streaming timeout for message: $messageId', + ); + streamCompleted = true; + streamController.addError('Streaming timeout'); + streamController.close(); + } + }); + + // Set up listener for chat-events from the server (OpenWebUI pattern) + void handleChatEvent(dynamic data) { + try { + if (streamCompleted) return; + + debugPrint('DEBUG: Received Socket.IO chat event: $data'); + + final Map eventData = data is Map + ? data + : (data as Map).cast(); + + final chatId = eventData['chat_id']?.toString(); + final eventMessageId = eventData['message_id']?.toString(); + final eventDetails = eventData['data'] as Map? ?? {}; + + final eventType = eventDetails['type']?.toString(); + final eventDataContent = + eventDetails['data'] as Map? ?? {}; + + debugPrint( + 'DEBUG: Event type: $eventType, chat_id: $chatId, message_id: $eventMessageId', + ); + + // Only process events for our message + if (eventMessageId != messageId && eventMessageId != null) { + return; + } + + switch (eventType) { + case 'message': + // Incremental content streaming - add the new chunk + final content = eventDataContent['content']?.toString() ?? ''; + if (content.isNotEmpty) { + debugPrint('DEBUG: Adding Socket.IO content chunk: "$content"'); + streamController.add(content); + } + break; + + case 'replace': + // Full content replacement - replace entire content + final content = eventDataContent['content']?.toString() ?? ''; + debugPrint('DEBUG: Replacing Socket.IO content: "$content"'); + streamController.add('__REPLACE_CONTENT__$content'); + break; + + case 'status': + // Status update (like "generating", "thinking", etc.) + final status = eventDataContent['status']?.toString() ?? ''; + if (status.isNotEmpty) { + debugPrint('DEBUG: Socket.IO Status update: $status'); + // Optionally emit status as a special event + streamController.add('__STATUS__$status'); + } + break; + + case 'error': + // Error occurred during generation + final error = + eventDataContent['error']?.toString() ?? 'Unknown error'; + debugPrint('DEBUG: Socket.IO streaming error: $error'); + streamCompleted = true; + timeoutTimer?.cancel(); + _socket?.off('chat-events', handleChatEvent); + streamController.addError(error); + streamController.close(); + break; + + case 'done': + // Streaming completed successfully + debugPrint( + 'DEBUG: Socket.IO streaming completed for message: $messageId', + ); + streamCompleted = true; + timeoutTimer?.cancel(); + _socket?.off('chat-events', handleChatEvent); + streamController.close(); + break; + + default: + debugPrint('DEBUG: Unknown Socket.IO event type: $eventType'); + break; + } + } catch (e, stackTrace) { + debugPrint('DEBUG: Error handling Socket.IO event: $e'); + debugPrint('DEBUG: Stack trace: $stackTrace'); + if (!streamCompleted) { + streamCompleted = true; + timeoutTimer?.cancel(); + _socket?.off('chat-events', handleChatEvent); + streamController.addError('Error processing streaming event: $e'); + streamController.close(); + } + } + } + + // Listen for chat-events + _socket!.on('chat-events', handleChatEvent); + + // Clean up when stream is closed + streamController.onCancel = () { + debugPrint( + 'DEBUG: Cleaning up Socket.IO listeners for message: $messageId', + ); + streamCompleted = true; + timeoutTimer?.cancel(); + _socket?.off('chat-events', handleChatEvent); + }; + } + + // Send chat completion request that will trigger Socket.IO events + Future _sendChatCompletionForSocketIO({ + required List> messages, + required String model, + String? conversationId, + required String messageId, + List>? tools, + bool enableWebSearch = false, + Map? modelItem, + }) async { + try { + // Process messages same as SSE version + final processedMessages = messages.map((message) { + final role = message['role'] as String; + final content = message['content']; + final files = message['files'] as List>?; + + final isContentArray = content is List; + final hasImages = + files?.any((file) => file['type'] == 'image') ?? false; + + if (isContentArray) { + return {'role': role, 'content': content}; + } else if (hasImages && role == 'user') { + final imageFiles = files! + .where((file) => file['type'] == 'image') + .toList(); + final contentText = content is String ? content : ''; + final contentArray = >[ + {'type': 'text', 'text': contentText}, + ]; + + for (final file in imageFiles) { + contentArray.add({ + 'type': 'image_url', + 'image_url': {'url': file['url']}, + }); + } + + return {'role': role, 'content': contentArray}; + } else { + final contentText = content is String ? content : ''; + return {'role': role, 'content': contentText}; + } + }).toList(); + + // Separate files from messages + final allFiles = >[]; + for (final message in messages) { + final files = message['files'] as List>?; + if (files != null) { + final nonImageFiles = files + .where((file) => file['type'] != 'image') + .toList(); + allFiles.addAll(nonImageFiles); + } + } + + // Create request data + final data = { + 'model': model, + 'messages': processedMessages, + 'stream': true, // Enable streaming + 'message_id': messageId, // Include message ID for Socket.IO events + if (conversationId != null) 'chat_id': conversationId, + if (tools != null && tools.isNotEmpty) 'tools': tools, + if (allFiles.isNotEmpty) 'files': allFiles, + if (enableWebSearch) 'web_search': enableWebSearch, + 'session_id': _socket?.id, // Include Socket.IO session ID + }; + + debugPrint('DEBUG: Sending Socket.IO-enabled chat completion request'); + debugPrint('DEBUG: Message ID: $messageId'); + debugPrint('DEBUG: Socket ID: ${_socket?.id}'); + + // Send the request - server should emit Socket.IO events in response + await _dio.post('/api/chat/completions', data: data); + } catch (e) { + debugPrint('DEBUG: Error sending Socket.IO chat completion request: $e'); + rethrow; + } + } + + // Enhanced SSE streaming method that matches OpenWebUI implementation + ({Stream stream, String messageId, String sessionId}) + sendMessageWithImprovedSSE({ + required List> messages, + required String model, + String? conversationId, + List>? tools, + bool enableWebSearch = false, + Map? modelItem, + }) { + final streamController = StreamController(); + + // Generate a unique message ID and session ID for the request + final messageId = const Uuid().v4(); + final sessionId = const Uuid().v4().substring(0, 20); // Match WebUI format + + // Check if this is a Gemini model that requires special handling + final isGeminiModel = model.toLowerCase().contains('gemini'); + debugPrint('DEBUG: Is Gemini model in API: $isGeminiModel'); + debugPrint('DEBUG: Model ID in API: $model'); + + // Process messages to match OpenWebUI format + final processedMessages = messages.map((message) { + final role = message['role'] as String; + final content = message['content']; + final files = message['files'] as List>?; + + // Check if content is already a List (content array format) + final isContentArray = content is List; + + // Check if this message has image files + final hasImages = files?.any((file) => file['type'] == 'image') ?? false; + + if (isContentArray) { + // Content is already in the correct array format + return {'role': role, 'content': content}; + } else if (hasImages && role == 'user') { + // For user messages with images, use OpenWebUI's content array format + final imageFiles = files! + .where((file) => file['type'] == 'image') + .toList(); + + final contentText = content is String ? content : ''; + final contentArray = >[ + {'type': 'text', 'text': contentText}, + ]; + + for (final file in imageFiles) { + contentArray.add({ + 'type': 'image_url', + 'image_url': {'url': file['url']}, + }); + } + + return {'role': role, 'content': contentArray}; + } else { + // For messages without images or non-user messages, use regular format + final contentText = content is String ? content : ''; + return {'role': role, 'content': contentText}; + } + }).toList(); + + // Separate files from messages (OpenWebUI format) + final allFiles = >[]; + for (final message in messages) { + final files = message['files'] as List>?; + if (files != null) { + // Only include non-image files in the files array + final nonImageFiles = files + .where((file) => file['type'] != 'image') + .toList(); + allFiles.addAll(nonImageFiles); + } + } + + // Prepare the request in OpenWebUI format + final data = { + 'stream': true, + 'model': model, + 'messages': processedMessages, + 'params': { + 'temperature': 0.7, + 'top_p': 1.0, + 'max_tokens': 4096, + 'stream_response': true, + }, + 'files': allFiles.isNotEmpty ? allFiles : null, + 'tool_servers': [], + 'features': { + 'image_generation': false, + 'code_interpreter': false, + 'web_search': enableWebSearch, + 'memory': false, + }, + 'variables': { + '{{USER_NAME}}': 'User', + '{{USER_LOCATION}}': 'Unknown', + '{{CURRENT_DATETIME}}': DateTime.now().toString().substring(0, 19), + '{{CURRENT_DATE}}': DateTime.now().toString().substring(0, 10), + '{{CURRENT_TIME}}': DateTime.now().toString().substring(11, 19), + '{{CURRENT_WEEKDAY}}': _getCurrentWeekday(), + '{{CURRENT_TIMEZONE}}': DateTime.now().timeZoneName, + '{{USER_LANGUAGE}}': 'en-US', + }, + if (conversationId != null) 'chat_id': conversationId, + if (modelItem != null) 'model_item': modelItem, + 'background_tasks': { + 'title_generation': true, + 'tags_generation': true, + 'follow_up_generation': true, + }, + 'session_id': sessionId, + 'id': messageId, + }; + + debugPrint('DEBUG: Sending chat completion request:'); + debugPrint('DEBUG: Model: $model'); + debugPrint('DEBUG: Messages count: ${processedMessages.length}'); + debugPrint('DEBUG: Files count: ${allFiles.length}'); + debugPrint('DEBUG: Web search enabled: $enableWebSearch'); + + // Use Server-Sent Events for streaming + const url = '/api/chat/completions'; + + _dio + .post( + url, + data: data, + options: Options( + responseType: ResponseType.stream, + headers: {'Accept': 'text/event-stream'}, + // Increase timeout for streaming responses + receiveTimeout: const Duration(minutes: 5), + ), + ) + .then((response) { + final stream = response.data.stream; + + stream.listen( + (data) { + final decodedData = utf8.decode(data); + debugPrint('DEBUG: SSE Raw data: $decodedData'); + final lines = decodedData.split('\n'); + for (final line in lines) { + if (line.startsWith('data: ')) { + final jsonStr = line.substring(6); + debugPrint('DEBUG: SSE JSON: $jsonStr'); + if (jsonStr == '[DONE]') { + debugPrint('DEBUG: Stream finished with [DONE]'); + streamController.close(); + return; + } + try { + final json = jsonDecode(jsonStr); + if (json is Map) { + final choices = json['choices']; + if (choices is List && choices.isNotEmpty) { + final delta = choices[0]['delta']; + if (delta is Map) { + // Handle regular content + final content = delta['content']; + if (content is String && content.isNotEmpty) { + debugPrint( + 'DEBUG: Adding content chunk: "$content"', + ); + streamController.add(content); + } + + // Handle function calls + final toolCalls = delta['tool_calls']; + if (toolCalls is List && toolCalls.isNotEmpty) { + for (final toolCall in toolCalls) { + if (toolCall is Map) { + final function = toolCall['function']; + if (function is Map) { + final name = function['name']; + final arguments = function['arguments']; + debugPrint( + 'DEBUG: Function call - Name: $name, Arguments: $arguments', + ); + } + } + } + } + } + } + } + } catch (e) { + debugPrint('DEBUG: Error parsing SSE data: $e'); + } + } + } + }, + onError: (error) { + debugPrint('DEBUG: Stream error: $error'); + debugPrint('DEBUG: Stream error type: ${error.runtimeType}'); + streamController.addError(error); + }, + onDone: () { + debugPrint('DEBUG: Stream completed'); + streamController.close(); + }, + ); + }) + .catchError((error) { + debugPrint('DEBUG: Request error: $error'); + streamController.addError(error); + }); + + return ( + stream: streamController.stream, + messageId: messageId, + sessionId: sessionId, + ); + } + + // File upload for RAG + Future uploadFile(String filePath, String fileName) async { + debugPrint('DEBUG: Starting file upload: $fileName from $filePath'); + + try { + // Check if file exists + final file = File(filePath); + if (!await file.exists()) { + throw Exception('File does not exist: $filePath'); + } + + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath, filename: fileName), + }); + + debugPrint('DEBUG: Uploading to /api/v1/files/'); + final response = await _dio.post('/api/v1/files/', data: formData); + + debugPrint('DEBUG: Upload response status: ${response.statusCode}'); + debugPrint('DEBUG: Upload response data: ${response.data}'); + + if (response.data is Map && response.data['id'] != null) { + final fileId = response.data['id'] as String; + debugPrint('DEBUG: File uploaded successfully with ID: $fileId'); + return fileId; + } else { + throw Exception('Invalid response format: missing file ID'); + } + } catch (e) { + debugPrint('ERROR: File upload failed: $e'); + rethrow; + } + } + + // Search conversations + Future> searchConversations(String query) async { + final response = await _dio.get( + '/api/v1/chats/search', + queryParameters: {'q': query}, + ); + final results = response.data as List; + return results.map((c) => Conversation.fromJson(c)).toList(); + } + + // Debug method to test API endpoints + Future debugApiEndpoints() async { + debugPrint('=== DEBUG API ENDPOINTS ==='); + debugPrint('Server URL: ${serverConfig.url}'); + debugPrint('Auth token present: ${authToken != null}'); + + // Test different possible endpoints + final endpoints = [ + '/api/v1/chats', + '/api/chats', + '/api/v1/conversations', + '/api/conversations', + ]; + + for (final endpoint in endpoints) { + try { + debugPrint('Testing endpoint: $endpoint'); + final response = await _dio.get(endpoint); + debugPrint('✅ $endpoint - Status: ${response.statusCode}'); + debugPrint(' Response type: ${response.data.runtimeType}'); + if (response.data is List) { + debugPrint(' Array length: ${(response.data as List).length}'); + } else if (response.data is Map) { + debugPrint(' Object keys: ${(response.data as Map).keys}'); + } + debugPrint( + ' Sample data: ${response.data.toString().substring(0, 200)}...', + ); + } catch (e) { + debugPrint('❌ $endpoint - Error: $e'); + } + debugPrint('---'); + } + debugPrint('=== END DEBUG ==='); + } + + // Check if server has API documentation + Future checkApiDocumentation() async { + debugPrint('=== CHECKING API DOCUMENTATION ==='); + final docEndpoints = ['/docs', '/api/docs', '/swagger', '/api/swagger']; + + for (final endpoint in docEndpoints) { + try { + final response = await _dio.get(endpoint); + if (response.statusCode == 200) { + debugPrint('✅ API docs available at: ${serverConfig.url}$endpoint'); + if (response.data is String && + response.data.toString().contains('swagger')) { + debugPrint(' This appears to be Swagger documentation'); + } + } + } catch (e) { + debugPrint('❌ No docs at $endpoint'); + } + } + debugPrint('=== END API DOCS CHECK ==='); + } + + void dispose() { + _wsChannel?.sink.close(); + _wsChannel = null; + } + + // Helper method to get current weekday name + // ==================== ADVANCED CHAT FEATURES ==================== + // Chat import/export, bulk operations, and advanced search + + /// Import chat data from external sources + Future>> importChats({ + required List> chatsData, + String? folderId, + bool overwriteExisting = false, + }) async { + debugPrint('DEBUG: Importing ${chatsData.length} chats'); + final response = await _dio.post( + '/api/v1/chats/import', + data: { + 'chats': chatsData, + if (folderId != null) 'folder_id': folderId, + 'overwrite_existing': overwriteExisting, + }, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + /// Export chat data for backup or migration + Future>> exportChats({ + List? chatIds, + String? folderId, + bool includeMessages = true, + String? format, + }) async { + debugPrint( + 'DEBUG: Exporting chats${chatIds != null ? ' (${chatIds.length} chats)' : ''}', + ); + final queryParams = {}; + if (chatIds != null) queryParams['chat_ids'] = chatIds.join(','); + if (folderId != null) queryParams['folder_id'] = folderId; + if (!includeMessages) queryParams['include_messages'] = false; + if (format != null) queryParams['format'] = format; + + final response = await _dio.get( + '/api/v1/chats/export', + queryParameters: queryParams, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + /// Archive all chats in bulk + Future> archiveAllChats({ + List? excludeIds, + String? beforeDate, + }) async { + debugPrint('DEBUG: Archiving all chats in bulk'); + final response = await _dio.post( + '/api/v1/chats/archive/all', + data: { + if (excludeIds != null) 'exclude_ids': excludeIds, + if (beforeDate != null) 'before_date': beforeDate, + }, + ); + return response.data as Map; + } + + /// Delete all chats in bulk + Future> deleteAllChats({ + List? excludeIds, + String? beforeDate, + bool archived = false, + }) async { + debugPrint('DEBUG: Deleting all chats in bulk (archived: $archived)'); + final response = await _dio.post( + '/api/v1/chats/delete/all', + data: { + if (excludeIds != null) 'exclude_ids': excludeIds, + if (beforeDate != null) 'before_date': beforeDate, + 'archived_only': archived, + }, + ); + return response.data as Map; + } + + /// Get pinned chats + Future> getPinnedChats() async { + debugPrint('DEBUG: Fetching pinned chats'); + final response = await _dio.get('/api/v1/chats/pinned'); + final data = response.data; + if (data is List) { + return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + } + return []; + } + + /// Get archived chats + Future> getArchivedChats({int? limit, int? offset}) async { + debugPrint('DEBUG: Fetching archived chats'); + final queryParams = {}; + if (limit != null) queryParams['limit'] = limit; + if (offset != null) queryParams['offset'] = offset; + + final response = await _dio.get( + '/api/v1/chats/archived', + queryParameters: queryParams, + ); + final data = response.data; + if (data is List) { + return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + } + return []; + } + + /// Advanced search for chats and messages + Future> searchChats({ + String? query, + String? userId, + String? model, + String? tag, + String? folderId, + DateTime? fromDate, + DateTime? toDate, + bool? pinned, + bool? archived, + int? limit, + int? offset, + String? sortBy, + String? sortOrder, + }) async { + debugPrint('DEBUG: Searching chats with query: $query'); + final queryParams = {}; + if (query != null) queryParams['q'] = query; + if (userId != null) queryParams['user_id'] = userId; + if (model != null) queryParams['model'] = model; + if (tag != null) queryParams['tag'] = tag; + if (folderId != null) queryParams['folder_id'] = folderId; + if (fromDate != null) queryParams['from_date'] = fromDate.toIso8601String(); + if (toDate != null) queryParams['to_date'] = toDate.toIso8601String(); + if (pinned != null) queryParams['pinned'] = pinned; + if (archived != null) queryParams['archived'] = archived; + if (limit != null) queryParams['limit'] = limit; + if (offset != null) queryParams['offset'] = offset; + if (sortBy != null) queryParams['sort_by'] = sortBy; + if (sortOrder != null) queryParams['sort_order'] = sortOrder; + + final response = await _dio.get( + '/api/v1/chats/search', + queryParameters: queryParams, + ); + return response.data as Map; + } + + /// Search within messages content + Future>> searchMessages({ + required String query, + String? chatId, + String? userId, + String? role, // 'user' or 'assistant' + DateTime? fromDate, + DateTime? toDate, + int? limit, + int? offset, + }) async { + debugPrint('DEBUG: Searching messages with query: $query'); + final response = await _dio.post( + '/api/v1/chats/messages/search', + data: { + 'query': query, + if (chatId != null) 'chat_id': chatId, + if (userId != null) 'user_id': userId, + if (role != null) 'role': role, + if (fromDate != null) 'from_date': fromDate.toIso8601String(), + if (toDate != null) 'to_date': toDate.toIso8601String(), + if (limit != null) 'limit': limit, + if (offset != null) 'offset': offset, + }, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + /// Get chat statistics and analytics + Future> getChatStats({ + String? userId, + DateTime? fromDate, + DateTime? toDate, + }) async { + debugPrint('DEBUG: Fetching chat statistics'); + final queryParams = {}; + if (userId != null) queryParams['user_id'] = userId; + if (fromDate != null) queryParams['from_date'] = fromDate.toIso8601String(); + if (toDate != null) queryParams['to_date'] = toDate.toIso8601String(); + + final response = await _dio.get( + '/api/v1/chats/stats', + queryParameters: queryParams, + ); + return response.data as Map; + } + + /// Duplicate/copy a chat + Future duplicateChat(String chatId, {String? title}) async { + debugPrint('DEBUG: Duplicating chat: $chatId'); + final response = await _dio.post( + '/api/v1/chats/$chatId/duplicate', + data: {if (title != null) 'title': title}, + ); + return _parseFullOpenWebUIChat(response.data as Map); + } + + /// Get recent chats with activity + Future> getRecentChats({int limit = 10, int? days}) async { + debugPrint('DEBUG: Fetching recent chats (limit: $limit)'); + final queryParams = {'limit': limit}; + if (days != null) queryParams['days'] = days; + + final response = await _dio.get( + '/api/v1/chats/recent', + queryParameters: queryParams, + ); + final data = response.data; + if (data is List) { + return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + } + return []; + } + + /// Get chat history with pagination and filters + Future> getChatHistory({ + int? limit, + int? offset, + String? cursor, + String? model, + String? tag, + bool? pinned, + bool? archived, + String? sortBy, + String? sortOrder, + }) async { + debugPrint('DEBUG: Fetching chat history with filters'); + final queryParams = {}; + if (limit != null) queryParams['limit'] = limit; + if (offset != null) queryParams['offset'] = offset; + if (cursor != null) queryParams['cursor'] = cursor; + if (model != null) queryParams['model'] = model; + if (tag != null) queryParams['tag'] = tag; + if (pinned != null) queryParams['pinned'] = pinned; + if (archived != null) queryParams['archived'] = archived; + if (sortBy != null) queryParams['sort_by'] = sortBy; + if (sortOrder != null) queryParams['sort_order'] = sortOrder; + + final response = await _dio.get( + '/api/v1/chats/history', + queryParameters: queryParams, + ); + return response.data as Map; + } + + /// Batch operations on multiple chats + Future> batchChatOperation({ + required List chatIds, + required String + operation, // 'archive', 'delete', 'pin', 'unpin', 'move_to_folder' + Map? params, + }) async { + debugPrint( + 'DEBUG: Performing batch operation "$operation" on ${chatIds.length} chats', + ); + final response = await _dio.post( + '/api/v1/chats/batch', + data: { + 'chat_ids': chatIds, + 'operation': operation, + if (params != null) 'params': params, + }, + ); + return response.data as Map; + } + + /// Get suggested prompts based on chat history + Future> getChatSuggestions({ + String? context, + int limit = 5, + }) async { + debugPrint('DEBUG: Fetching chat suggestions'); + final queryParams = {'limit': limit}; + if (context != null) queryParams['context'] = context; + + final response = await _dio.get( + '/api/v1/chats/suggestions', + queryParameters: queryParams, + ); + final data = response.data; + if (data is List) { + return data.cast(); + } + return []; + } + + /// Get chat templates for quick starts + Future>> getChatTemplates({ + String? category, + String? tag, + }) async { + debugPrint('DEBUG: Fetching chat templates'); + final queryParams = {}; + if (category != null) queryParams['category'] = category; + if (tag != null) queryParams['tag'] = tag; + + final response = await _dio.get( + '/api/v1/chats/templates', + queryParameters: queryParams, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + /// Create a chat from template + Future createChatFromTemplate( + String templateId, { + Map? variables, + String? title, + }) async { + debugPrint('DEBUG: Creating chat from template: $templateId'); + final response = await _dio.post( + '/api/v1/chats/templates/$templateId/create', + data: { + if (variables != null) 'variables': variables, + if (title != null) 'title': title, + }, + ); + return _parseFullOpenWebUIChat(response.data as Map); + } + + // ==================== END ADVANCED CHAT FEATURES ==================== + + // Enhanced streaming method that uses improved SSE (like OpenWebUI) and Socket.IO fallback + ({Stream stream, String messageId, String sessionId}) + sendMessageWithStreaming({ + required List> messages, + required String model, + String? conversationId, + List>? tools, + bool enableWebSearch = false, + Map? modelItem, + bool preferSocketIO = false, // Changed default to false - SSE is primary + }) { + debugPrint('DEBUG: Starting streaming with SSE as primary method'); + + // Use improved SSE streaming as primary method (matches OpenWebUI exactly) + return sendMessageDirect( + messages: messages, + model: model, + conversationId: conversationId, + tools: tools, + enableWebSearch: enableWebSearch, + modelItem: modelItem, + ); + } + + // Enhanced streaming method with Socket.IO preference + ({Stream stream, String messageId, String sessionId}) + sendMessageWithEnhancedStreaming({ + required List> messages, + required String model, + String? conversationId, + List>? tools, + bool enableWebSearch = false, + Map? modelItem, + bool preferSocketIO = true, + }) { + debugPrint( + 'DEBUG: Starting enhanced streaming with preferSocketIO: $preferSocketIO', + ); + + // Try Socket.IO first if preferred and available + if (preferSocketIO) { + try { + debugPrint('DEBUG: Attempting Socket.IO streaming...'); + return sendMessageWithSocketIO( + messages: messages, + model: model, + conversationId: conversationId, + tools: tools, + enableWebSearch: enableWebSearch, + modelItem: modelItem, + ); + } catch (e) { + debugPrint( + 'DEBUG: Socket.IO streaming failed, falling back to SSE: $e', + ); + // Fall through to SSE + } + } + + // Use SSE streaming as fallback + debugPrint('DEBUG: Using SSE streaming as fallback'); + return sendMessageDirect( + messages: messages, + model: model, + conversationId: conversationId, + tools: tools, + enableWebSearch: enableWebSearch, + modelItem: modelItem, + ); + } +} diff --git a/lib/core/services/attachment_upload_queue.dart b/lib/core/services/attachment_upload_queue.dart new file mode 100644 index 0000000..47b98e7 --- /dev/null +++ b/lib/core/services/attachment_upload_queue.dart @@ -0,0 +1,347 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Status of a queued attachment upload +enum QueuedAttachmentStatus { pending, uploading, completed, failed, cancelled } + +/// Metadata for a queued attachment +class QueuedAttachment { + final String id; // local queue id + final String filePath; + final String fileName; + final int fileSize; + final String? mimeType; + final String? checksum; + final DateTime enqueuedAt; + + // Upload state + int retryCount; + DateTime? nextRetryAt; + QueuedAttachmentStatus status; + String? lastError; + String? fileId; // server-side file id once uploaded + + QueuedAttachment({ + required this.id, + required this.filePath, + required this.fileName, + required this.fileSize, + this.mimeType, + this.checksum, + DateTime? enqueuedAt, + this.retryCount = 0, + this.nextRetryAt, + this.status = QueuedAttachmentStatus.pending, + this.lastError, + this.fileId, + }) : enqueuedAt = enqueuedAt ?? DateTime.now(); + + Map toJson() => { + 'id': id, + 'filePath': filePath, + 'fileName': fileName, + 'fileSize': fileSize, + 'mimeType': mimeType, + 'checksum': checksum, + 'enqueuedAt': enqueuedAt.toIso8601String(), + 'retryCount': retryCount, + 'nextRetryAt': nextRetryAt?.toIso8601String(), + 'status': status.name, + 'lastError': lastError, + 'fileId': fileId, + }; + + factory QueuedAttachment.fromJson(Map json) => + QueuedAttachment( + id: json['id'] as String, + filePath: json['filePath'] as String, + fileName: json['fileName'] as String, + fileSize: (json['fileSize'] as num).toInt(), + mimeType: json['mimeType'] as String?, + checksum: json['checksum'] as String?, + enqueuedAt: + DateTime.tryParse(json['enqueuedAt'] ?? '') ?? DateTime.now(), + retryCount: (json['retryCount'] as num?)?.toInt() ?? 0, + nextRetryAt: json['nextRetryAt'] != null + ? DateTime.tryParse(json['nextRetryAt']) + : null, + status: QueuedAttachmentStatus.values.firstWhere( + (e) => e.name == json['status'], + orElse: () => QueuedAttachmentStatus.pending, + ), + lastError: json['lastError'] as String?, + fileId: json['fileId'] as String?, + ); + + QueuedAttachment copyWith({ + int? retryCount, + DateTime? nextRetryAt, + QueuedAttachmentStatus? status, + String? lastError, + String? fileId, + }) => QueuedAttachment( + id: id, + filePath: filePath, + fileName: fileName, + fileSize: fileSize, + mimeType: mimeType, + checksum: checksum, + enqueuedAt: enqueuedAt, + retryCount: retryCount ?? this.retryCount, + nextRetryAt: nextRetryAt ?? this.nextRetryAt, + status: status ?? this.status, + lastError: lastError ?? this.lastError, + fileId: fileId ?? this.fileId, + ); +} + +typedef UploadCallback = + Future Function(String filePath, String fileName); +typedef AttachmentsEventCallback = void Function(List queue); + +/// A lightweight background queue to upload attachments when back online. +class AttachmentUploadQueue { + static final AttachmentUploadQueue _instance = + AttachmentUploadQueue._internal(); + factory AttachmentUploadQueue() => _instance; + AttachmentUploadQueue._internal(); + + static const String _prefsKey = 'attachment_upload_queue'; + static const int _maxRetries = 4; + static const Duration _baseRetryDelay = Duration(seconds: 5); + static const Duration _maxRetryDelay = Duration(minutes: 5); + + SharedPreferences? _prefs; + final List _queue = []; + Timer? _retryTimer; + bool _isProcessing = false; + + // Dependencies + UploadCallback? _onUpload; + AttachmentsEventCallback? _onQueueChanged; + + // Streams + final _queueController = StreamController>.broadcast(); + Stream> get queueStream => _queueController.stream; + + List get queue => List.unmodifiable(_queue); + + Future initialize({ + required UploadCallback onUpload, + AttachmentsEventCallback? onQueueChanged, + }) async { + _onUpload = onUpload; + _onQueueChanged = onQueueChanged; + _prefs ??= await SharedPreferences.getInstance(); + await _load(); + _startPeriodicProcessing(); + debugPrint( + 'DEBUG: AttachmentUploadQueue initialized with ${_queue.length} items', + ); + } + + Future enqueue({ + required String filePath, + required String fileName, + required int fileSize, + String? mimeType, + String? checksum, + }) async { + final id = DateTime.now().microsecondsSinceEpoch.toString(); + final item = QueuedAttachment( + id: id, + filePath: filePath, + fileName: fileName, + fileSize: fileSize, + mimeType: mimeType, + checksum: checksum, + status: QueuedAttachmentStatus.pending, + ); + _queue.add(item); + await _save(); + _notify(); + _processSafe(); + return id; + } + + Future processQueue() async { + if (_isProcessing) return; + if (_onUpload == null) return; + + _isProcessing = true; + try { + // Quick network probe using Dio HEAD to common health path if possible + final dio = Dio(); + try { + await dio.head('/api/health').timeout(const Duration(seconds: 3)); + } catch (_) { + // Best effort; continue and let upload fail if actually offline + } + + final now = DateTime.now(); + final pending = _queue.where( + (e) => + (e.status == QueuedAttachmentStatus.pending || + e.status == QueuedAttachmentStatus.failed) && + (e.nextRetryAt == null || now.isAfter(e.nextRetryAt!)), + ); + + for (final item in List.from(pending)) { + await _processSingle(item); + } + } finally { + _isProcessing = false; + } + } + + Future _processSingle(QueuedAttachment item) async { + if (_onUpload == null) return; + try { + _update(item.id, item.copyWith(status: QueuedAttachmentStatus.uploading)); + + final fileId = await _onUpload!.call(item.filePath, item.fileName); + + _update( + item.id, + item.copyWith( + status: QueuedAttachmentStatus.completed, + fileId: fileId, + retryCount: 0, + nextRetryAt: null, + lastError: null, + ), + ); + + await _save(); + _notify(); + debugPrint( + 'DEBUG: Attachment ${item.id} uploaded successfully (fileId=$fileId)', + ); + } catch (e) { + final retries = item.retryCount + 1; + if (retries >= _maxRetries) { + _update( + item.id, + item.copyWith( + status: QueuedAttachmentStatus.failed, + retryCount: retries, + lastError: e.toString(), + ), + ); + await _save(); + _notify(); + debugPrint( + 'WARNING: Attachment ${item.id} failed after $_maxRetries attempts', + ); + return; + } + + final delay = _retryDelayWithJitter(retries); + _update( + item.id, + item.copyWith( + status: QueuedAttachmentStatus.pending, + retryCount: retries, + nextRetryAt: DateTime.now().add(delay), + lastError: e.toString(), + ), + ); + await _save(); + _notify(); + debugPrint( + 'DEBUG: Scheduled retry for attachment ${item.id} in ${delay.inSeconds}s', + ); + } + } + + Duration _retryDelayWithJitter(int retryCount) { + final base = _baseRetryDelay.inMilliseconds; + final exp = min( + base * pow(2, retryCount - 1), + _maxRetryDelay.inMilliseconds.toDouble(), + ).toInt(); + final jitter = Random().nextInt(1000); // up to 1s jitter + return Duration(milliseconds: exp + jitter); + } + + void _startPeriodicProcessing() { + _retryTimer?.cancel(); + _retryTimer = Timer.periodic( + const Duration(seconds: 10), + (_) => _processSafe(), + ); + // Also kick once after a short delay + Timer(const Duration(milliseconds: 500), _processSafe); + } + + void _processSafe() { + // Fire and forget + unawaited(processQueue()); + } + + void _update(String id, QueuedAttachment updated) { + final idx = _queue.indexWhere((e) => e.id == id); + if (idx != -1) { + _queue[idx] = updated; + } + } + + Future remove(String id) async { + _queue.removeWhere((e) => e.id == id); + await _save(); + _notify(); + } + + Future retry(String id) async { + final idx = _queue.indexWhere((e) => e.id == id); + if (idx == -1) return; + _queue[idx] = _queue[idx].copyWith( + status: QueuedAttachmentStatus.pending, + retryCount: 0, + nextRetryAt: null, + lastError: null, + ); + await _save(); + _notify(); + _processSafe(); + } + + Future clearFailed() async { + _queue.removeWhere((e) => e.status == QueuedAttachmentStatus.failed); + await _save(); + _notify(); + } + + Future clearAll() async { + _queue.clear(); + await _save(); + _notify(); + } + + // Utilities + Future _load() async { + final jsonStr = (_prefs ?? await SharedPreferences.getInstance()).getString( + _prefsKey, + ); + if (jsonStr == null || jsonStr.isEmpty) return; + final list = (jsonDecode(jsonStr) as List).cast>(); + _queue + ..clear() + ..addAll(list.map(QueuedAttachment.fromJson)); + } + + Future _save() async { + final prefs = _prefs ?? await SharedPreferences.getInstance(); + final list = _queue.map((e) => e.toJson()).toList(growable: false); + await prefs.setString(_prefsKey, jsonEncode(list)); + } + + void _notify() { + _onQueueChanged?.call(queue); + _queueController.add(queue); + } +} diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/connectivity_service.dart new file mode 100644 index 0000000..a922d39 --- /dev/null +++ b/lib/core/services/connectivity_service.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import '../providers/app_providers.dart'; + +enum ConnectivityStatus { online, offline, checking } + +class ConnectivityService { + final Dio _dio; + Timer? _connectivityTimer; + final _connectivityController = + StreamController.broadcast(); + ConnectivityStatus _lastStatus = ConnectivityStatus.checking; + + ConnectivityService(this._dio) { + _startConnectivityMonitoring(); + } + + Stream get connectivityStream => + _connectivityController.stream; + ConnectivityStatus get currentStatus => _lastStatus; + + void _startConnectivityMonitoring() { + // Initial check after a brief delay to avoid showing offline during startup + Timer(const Duration(milliseconds: 1000), () { + _checkConnectivity(); + }); + + // Check every 5 seconds + _connectivityTimer = Timer.periodic(const Duration(seconds: 5), (_) { + _checkConnectivity(); + }); + } + + Future _checkConnectivity() async { + try { + // DNS lookup is a lightweight, permission-free reachability check + final result = await InternetAddress.lookup( + 'google.com', + ).timeout(const Duration(seconds: 3)); + + if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { + _updateStatus(ConnectivityStatus.online); + return; + } + } catch (_) { + // Swallow and continue to HTTP reachability check + } + + // As a secondary check, hit a public 204 endpoint that returns quickly + try { + await _dio + .get( + 'https://www.google.com/generate_204', + options: Options( + method: 'GET', + sendTimeout: const Duration(seconds: 3), + receiveTimeout: const Duration(seconds: 3), + followRedirects: false, + validateStatus: (status) => status != null && status < 400, + ), + ) + .timeout(const Duration(seconds: 3)); + _updateStatus(ConnectivityStatus.online); + } catch (_) { + _updateStatus(ConnectivityStatus.offline); + } + } + + void _updateStatus(ConnectivityStatus status) { + if (_lastStatus != status) { + _lastStatus = status; + _connectivityController.add(status); + } + } + + Future checkConnectivity() async { + await _checkConnectivity(); + return _lastStatus == ConnectivityStatus.online; + } + + void dispose() { + _connectivityTimer?.cancel(); + _connectivityController.close(); + } +} + +// Providers +final connectivityServiceProvider = Provider((ref) { + final dio = ref.watch(dioProvider); + final service = ConnectivityService(dio); + ref.onDispose(() => service.dispose()); + return service; +}); + +final connectivityStatusProvider = StreamProvider((ref) { + final service = ref.watch(connectivityServiceProvider); + return service.connectivityStream; +}); + +final isOnlineProvider = Provider((ref) { + // In reviewer mode, treat app as online to enable flows + final reviewerMode = ref.watch(reviewerModeProvider); + if (reviewerMode) return true; + final status = ref.watch(connectivityStatusProvider); + return status.when( + data: (status) => status == ConnectivityStatus.online, + loading: () => true, // Assume online while checking + error: (_, _) => + true, // Assume online on error to avoid false offline states + ); +}); + +// Dio provider (if not already defined elsewhere) +final dioProvider = Provider((ref) { + return Dio(); // This should be configured with your base URL +}); diff --git a/lib/core/services/deep_link_service.dart b/lib/core/services/deep_link_service.dart new file mode 100644 index 0000000..9ce110a --- /dev/null +++ b/lib/core/services/deep_link_service.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../features/chat/views/chat_page.dart'; +import '../../features/files/views/files_page.dart'; +import '../../features/profile/views/profile_page.dart'; + +/// Service for handling deep links and navigation routing +class DeepLinkService { + /// Route to chat tab + static void navigateToChat(BuildContext context) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const ChatPage()), + (route) => false, + ); + } + + /// In single-screen mode, files/profile deep links route via navigator + static void navigateToFiles(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FilesPage()), + ); + } + + static void navigateToProfile(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ProfilePage()), + ); + } + + /// Parse route and determine target tab + static String? parsePath(String route) { + switch (route) { + case '/chat': + case '/main/chat': + return '/chat'; + case '/files': + case '/main/files': + return '/files'; + case '/profile': + case '/main/profile': + return '/profile'; + default: + return null; + } + } + + /// Handle deep link navigation + static Widget handleDeepLink(String route) { + final path = parsePath(route); + switch (path) { + case '/files': + return const FilesPage(); + case '/profile': + return const ProfilePage(); + case '/chat': + default: + return const ChatPage(); + } + } +} + +/// Provider for deep link navigation +final deepLinkProvider = Provider((ref) => DeepLinkService()); diff --git a/lib/core/services/enhanced_accessibility_service.dart b/lib/core/services/enhanced_accessibility_service.dart new file mode 100644 index 0000000..01bf7dd --- /dev/null +++ b/lib/core/services/enhanced_accessibility_service.dart @@ -0,0 +1,396 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/semantics.dart'; +import '../../shared/theme/app_theme.dart'; +import '../../shared/theme/theme_extensions.dart'; + +/// Enhanced accessibility service for WCAG 2.2 AA compliance +class EnhancedAccessibilityService { + /// Announce text to screen readers + static void announce( + String message, { + TextDirection textDirection = TextDirection.ltr, + }) { + SemanticsService.announce(message, textDirection); + } + + /// Announce loading state + static void announceLoading(String loadingMessage) { + announce('Loading: $loadingMessage'); + } + + /// Announce error with helpful context + static void announceError(String error, {String? suggestion}) { + final message = suggestion != null + ? 'Error: $error. $suggestion' + : 'Error: $error'; + announce(message); + } + + /// Announce success with context + static void announceSuccess(String successMessage) { + announce('Success: $successMessage'); + } + + /// Check if reduce motion is enabled + static bool shouldReduceMotion(BuildContext context) { + return MediaQuery.of(context).disableAnimations; + } + + /// Get appropriate animation duration based on motion settings + static Duration getAnimationDuration( + BuildContext context, + Duration defaultDuration, + ) { + return shouldReduceMotion(context) ? Duration.zero : defaultDuration; + } + + /// Get text scale factor with bounds for accessibility + static double getBoundedTextScaleFactor(BuildContext context) { + final textScaler = MediaQuery.of(context).textScaler; + final textScaleFactor = textScaler.scale(1.0); + // Ensure text doesn't get too small or too large + return textScaleFactor.clamp(0.8, 3.0); + } + + /// Create accessible button with proper semantics + static Widget createAccessibleButton({ + required Widget child, + required VoidCallback? onPressed, + required String semanticLabel, + String? semanticHint, + bool isDestructive = false, + }) { + return Builder( + builder: (context) => Semantics( + label: semanticLabel, + hint: semanticHint, + button: true, + enabled: onPressed != null, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + minimumSize: const Size(44, 44), // WCAG minimum touch target + backgroundColor: isDestructive ? context.conduitTheme.error : null, + ), + child: child, + ), + ), + ); + } + + /// Create accessible icon button with proper semantics + static Widget createAccessibleIconButton({ + required IconData icon, + required VoidCallback? onPressed, + required String semanticLabel, + String? semanticHint, + Color? iconColor, + double iconSize = 24, + }) { + return Semantics( + label: semanticLabel, + hint: semanticHint, + button: true, + enabled: onPressed != null, + child: SizedBox( + width: 44, // Minimum touch target + height: 44, + child: IconButton( + onPressed: onPressed, + icon: Icon(icon, size: iconSize, color: iconColor), + padding: EdgeInsets.zero, + ), + ), + ); + } + + /// Create accessible text field with proper labels + static Widget createAccessibleTextField({ + required String label, + TextEditingController? controller, + String? hintText, + String? errorText, + bool isRequired = false, + TextInputType? keyboardType, + bool obscureText = false, + ValueChanged? onChanged, + }) { + final effectiveLabel = isRequired ? '$label *' : label; + + return Semantics( + label: effectiveLabel, + hint: hintText, + textField: true, + child: TextFormField( + controller: controller, + keyboardType: keyboardType, + obscureText: obscureText, + onChanged: onChanged, + decoration: InputDecoration( + labelText: effectiveLabel, + hintText: hintText, + errorText: errorText, + helperText: isRequired ? '* Required field' : null, + prefixIcon: errorText != null + ? Builder( + builder: (context) => Icon( + Icons.error_outline, + color: context.conduitTheme.error, + ), + ) + : null, + ), + ), + ); + } + + /// Create accessible card with proper semantics + static Widget createAccessibleCard({ + required Widget child, + VoidCallback? onTap, + String? semanticLabel, + String? semanticHint, + bool isSelected = false, + }) { + return Semantics( + label: semanticLabel, + hint: semanticHint, + button: onTap != null, + selected: isSelected, + child: Card( + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: child, + ), + ), + ), + ); + } + + /// Create accessible loading indicator + static Widget createAccessibleLoadingIndicator({ + String? loadingMessage, + double size = 24, + }) { + return Semantics( + label: loadingMessage ?? 'Loading', + liveRegion: true, + child: SizedBox( + width: size, + height: size, + child: const CircularProgressIndicator(), + ), + ); + } + + /// Create accessible image with alt text + static Widget createAccessibleImage({ + required ImageProvider image, + required String altText, + bool isDecorative = false, + double? width, + double? height, + BoxFit fit = BoxFit.cover, + }) { + if (isDecorative) { + return Semantics( + excludeSemantics: true, + child: Image(image: image, width: width, height: height, fit: fit), + ); + } + + return Semantics( + label: altText, + image: true, + child: Image(image: image, width: width, height: height, fit: fit), + ); + } + + /// Create accessible toggle switch + static Widget createAccessibleSwitch({ + required bool value, + required ValueChanged? onChanged, + required String label, + String? description, + }) { + return Builder( + builder: (context) => Semantics( + label: label, + value: value ? 'On' : 'Off', + hint: description, + toggled: value, + onTap: onChanged != null ? () => onChanged(!value) : null, + child: SwitchListTile( + title: Text( + label, + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + subtitle: description != null + ? Text( + description, + style: TextStyle(color: context.conduitTheme.textSecondary), + ) + : null, + value: value, + onChanged: onChanged, + ), + ), + ); + } + + /// Create accessible slider + static Widget createAccessibleSlider({ + required double value, + required ValueChanged? onChanged, + required String label, + double min = 0.0, + double max = 1.0, + int? divisions, + String Function(double)? valueFormatter, + }) { + final formattedValue = + valueFormatter?.call(value) ?? value.toStringAsFixed(1); + + return Semantics( + label: label, + value: formattedValue, + increasedValue: + valueFormatter?.call((value + 0.1).clamp(min, max)) ?? + (value + 0.1).clamp(min, max).toStringAsFixed(1), + decreasedValue: + valueFormatter?.call((value - 0.1).clamp(min, max)) ?? + (value - 0.1).clamp(min, max).toStringAsFixed(1), + onIncrease: onChanged != null + ? () => onChanged((value + 0.1).clamp(min, max)) + : null, + onDecrease: onChanged != null + ? () => onChanged((value - 0.1).clamp(min, max)) + : null, + child: Slider( + value: value, + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + label: formattedValue, + ), + ); + } + + /// Create accessible modal with focus management + static Future showAccessibleModal({ + required BuildContext context, + required Widget child, + required String title, + bool barrierDismissible = true, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (context) => Semantics( + scopesRoute: true, + explicitChildNodes: true, + label: 'Dialog: $title', + child: AlertDialog( + title: Semantics(header: true, child: Text(title)), + content: child, + ), + ), + ); + } + + /// Check color contrast ratio (simplified implementation) + static bool hasGoodContrast(Color foreground, Color background) { + // Simplified contrast calculation + final fgLuminance = _getLuminance(foreground); + final bgLuminance = _getLuminance(background); + + final lighter = fgLuminance > bgLuminance ? fgLuminance : bgLuminance; + final darker = fgLuminance > bgLuminance ? bgLuminance : fgLuminance; + + final contrast = (lighter + 0.05) / (darker + 0.05); + + // WCAG AA requires 4.5:1 for normal text, 3:1 for large text + return contrast >= 4.5; + } + + /// Calculate relative luminance of a color + static double _getLuminance(Color color) { + final r = _gammaCorrect((color.r * 255.0).round() / 255.0); + final g = _gammaCorrect((color.g * 255.0).round() / 255.0); + final b = _gammaCorrect((color.b * 255.0).round() / 255.0); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + /// Apply gamma correction + static double _gammaCorrect(double value) { + return value <= 0.03928 + ? value / 12.92 + : math.pow((value + 0.055) / 1.055, 2.4).toDouble(); + } + + /// Provide haptic feedback if available + static void hapticFeedback() { + HapticFeedback.lightImpact(); + } + + /// Create accessible focus border + static BoxDecoration createFocusBorder({ + required bool hasFocus, + Color? focusColor, + double borderWidth = 2.0, + BorderRadius? borderRadius, + }) { + return BoxDecoration( + border: hasFocus + ? Border.all( + color: + focusColor ?? + AppTheme.brandPrimary, // Brand primary as fallback + width: borderWidth, + ) + : null, + borderRadius: borderRadius, + ); + } + + /// Create accessible text with proper scaling + static Widget createAccessibleText( + String text, { + TextStyle? style, + TextAlign? textAlign, + bool isHeader = false, + int? maxLines, + }) { + return Builder( + builder: (context) { + final textScaleFactor = getBoundedTextScaleFactor(context); + + Widget textWidget = Text( + text, + style: + style?.copyWith( + fontSize: style.fontSize != null + ? style.fontSize! * textScaleFactor + : null, + ) ?? + TextStyle(fontSize: AppTypography.bodyLarge * textScaleFactor), + textAlign: textAlign, + maxLines: maxLines, + overflow: maxLines != null ? TextOverflow.ellipsis : null, + ); + + if (isHeader) { + textWidget = Semantics(header: true, child: textWidget); + } + + return textWidget; + }, + ); + } +} diff --git a/lib/core/services/error_handling_service.dart b/lib/core/services/error_handling_service.dart new file mode 100644 index 0000000..07c0b40 --- /dev/null +++ b/lib/core/services/error_handling_service.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import '../../shared/theme/theme_extensions.dart'; +import '../../shared/widgets/themed_dialogs.dart'; +import 'user_friendly_error_handler.dart'; + +class ErrorHandlingService { + static final _userFriendlyHandler = UserFriendlyErrorHandler(); + + static String getErrorMessage(dynamic error) { + // Use the enhanced user-friendly error handler + return _userFriendlyHandler.getUserMessage(error); + } + + /// Get recovery actions for an error + static List getRecoveryActions(dynamic error) { + return _userFriendlyHandler.getRecoveryActions(error); + } + + static void showErrorSnackBar( + BuildContext context, + dynamic error, { + VoidCallback? onRetry, + String? customMessage, + }) { + if (customMessage != null) { + // Use custom message if provided + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(customMessage), + backgroundColor: context.conduitTheme.error, + behavior: SnackBarBehavior.floating, + action: onRetry != null + ? SnackBarAction( + label: 'Retry', + textColor: context.conduitTheme.textInverse, + onPressed: onRetry, + ) + : null, + duration: const Duration(seconds: 4), + ), + ); + } else { + // Use enhanced error handler + _userFriendlyHandler.showErrorSnackbar(context, error, onRetry: onRetry); + } + } + + /// Show enhanced error dialog with recovery options + static Future showErrorDialog( + BuildContext context, + dynamic error, { + VoidCallback? onRetry, + bool showDetails = false, + }) async { + return _userFriendlyHandler.showErrorDialog( + context, + error, + onRetry: onRetry, + showDetails: showDetails, + ); + } + + static void showSuccessSnackBar( + BuildContext context, + String message, { + Duration? duration, + }) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: context.conduitTheme.success, + behavior: SnackBarBehavior.floating, + duration: duration ?? const Duration(seconds: 2), + ), + ); + } + + static Future showConfirmationDialog( + BuildContext context, { + required String title, + required String content, + String confirmText = 'Confirm', + String cancelText = 'Cancel', + bool isDestructive = false, + }) async { + return await ThemedDialogs.confirm( + context, + title: title, + message: content, + confirmText: confirmText, + cancelText: cancelText, + isDestructive: isDestructive, + ); + } + + static Widget buildErrorWidget({ + required String message, + VoidCallback? onRetry, + IconData? icon, + dynamic error, + }) { + if (error != null) { + // Use enhanced error handler for full error objects + return _userFriendlyHandler.buildErrorWidget(error, onRetry: onRetry); + } + + // Fallback to legacy implementation for string messages + return Builder( + builder: (context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon ?? Icons.error_outline, + size: Spacing.xxxl, + color: theme.colorScheme.error, + ), + const SizedBox(height: Spacing.md), + Text( + 'Something went wrong', + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.sm), + Text( + message, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const SizedBox(height: Spacing.lg), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + ), + ], + ], + ), + ), + ); + }, + ); + } + + /// Build enhanced error widget with recovery actions + static Widget buildEnhancedErrorWidget( + dynamic error, { + VoidCallback? onRetry, + VoidCallback? onDismiss, + bool showDetails = false, + }) { + return _userFriendlyHandler.buildErrorWidget( + error, + onRetry: onRetry, + onDismiss: onDismiss, + showDetails: showDetails, + ); + } + + static Widget buildLoadingWidget({String? message}) { + return Builder( + builder: (context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: theme.colorScheme.primary), + if (message != null) ...[ + const SizedBox(height: Spacing.md), + Text( + message, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ); + }, + ); + } + + static Widget buildEmptyStateWidget({ + required String title, + required String message, + IconData? icon, + Widget? action, + }) { + return Builder( + builder: (context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon ?? Icons.inbox_outlined, + size: Spacing.xxxl, + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + const SizedBox(height: Spacing.md), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.sm), + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + if (action != null) ...[ + const SizedBox(height: Spacing.lg), + action, + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/core/services/error_recovery_service.dart b/lib/core/services/error_recovery_service.dart new file mode 100644 index 0000000..58b3b7e --- /dev/null +++ b/lib/core/services/error_recovery_service.dart @@ -0,0 +1,373 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import '../../shared/theme/theme_extensions.dart'; + +/// Enhanced error recovery service with retry strategies and user feedback +class ErrorRecoveryService { + final Map _retryConfigs = {}; + final Map _lastRetryTimes = {}; + + ErrorRecoveryService(Dio dio); + + /// Execute an operation with automatic retry and recovery + Future executeWithRecovery({ + required String operationId, + required Future Function() operation, + RetryConfig? retryConfig, + RecoveryAction? recoveryAction, + }) async { + final config = retryConfig ?? RetryConfig.defaultConfig(); + _retryConfigs[operationId] = config; + + int attempts = 0; + Exception? lastError; + + while (attempts < config.maxRetries) { + try { + final result = await operation(); + _clearRetryState(operationId); + return result; + } catch (error) { + attempts++; + lastError = error is Exception ? error : Exception(error.toString()); + + final shouldRetry = _shouldRetry(error, attempts, config); + if (!shouldRetry || attempts >= config.maxRetries) { + break; + } + + // Execute recovery action if provided + if (recoveryAction != null) { + try { + await recoveryAction.execute(error, attempts); + } catch (recoveryError) { + // Recovery action failed, continue with retry + } + } + + // Wait before retry with exponential backoff + final delay = _calculateRetryDelay(attempts, config); + await Future.delayed(delay); + } + } + + _clearRetryState(operationId); + throw ErrorRecoveryException(lastError!, attempts); + } + + /// Check if we should retry based on error type and configuration + bool _shouldRetry(dynamic error, int attempts, RetryConfig config) { + if (attempts >= config.maxRetries) return false; + + // Check cooldown period + final lastRetry = _lastRetryTimes[config.operationId]; + if (lastRetry != null) { + final timeSinceLastRetry = DateTime.now().difference(lastRetry); + if (timeSinceLastRetry < config.cooldownPeriod) { + return false; + } + } + + // Network errors are usually retryable + if (error is DioException) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.connectionError: + return true; + case DioExceptionType.badResponse: + // Retry on server errors (5xx) but not client errors (4xx) + final statusCode = error.response?.statusCode; + return statusCode != null && statusCode >= 500; + default: + return false; + } + } + + // Check custom retry conditions + return config.retryCondition?.call(error) ?? false; + } + + Duration _calculateRetryDelay(int attempt, RetryConfig config) { + if (config.retryStrategy == RetryStrategy.exponentialBackoff) { + final baseDelay = config.baseDelay.inMilliseconds; + final delay = baseDelay * pow(2, attempt - 1); + final jitter = Random().nextDouble() * 0.1 * delay; // Add 10% jitter + return Duration(milliseconds: (delay + jitter).round()); + } else { + return config.baseDelay; + } + } + + void _clearRetryState(String operationId) { + _retryConfigs.remove(operationId); + _lastRetryTimes.remove(operationId); + } + + /// Get user-friendly error message + String getErrorMessage(dynamic error) { + if (error is ErrorRecoveryException) { + return _getRecoveryErrorMessage(error); + } + + if (error is DioException) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + return 'The connection is taking too long. Please check your internet and try again.'; + case DioExceptionType.sendTimeout: + return 'Failed to send your request. Please try again.'; + case DioExceptionType.receiveTimeout: + return 'The server is taking too long to respond. Please try again.'; + case DioExceptionType.connectionError: + return 'Unable to connect. Please check your internet connection.'; + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + if (statusCode == 401) { + return 'Your session has expired. Please sign in again.'; + } else if (statusCode == 403) { + return 'You don\'t have permission to perform this action.'; + } else if (statusCode == 404) { + return 'The requested resource was not found.'; + } else if (statusCode != null && statusCode >= 500) { + return 'The server is experiencing issues. Please try again later.'; + } + return 'Something went wrong with your request.'; + case DioExceptionType.cancel: + return 'The request was cancelled.'; + case DioExceptionType.badCertificate: + return 'There\'s a security issue with the connection.'; + case DioExceptionType.unknown: + return 'Something unexpected happened. Please try again.'; + } + } + + return error.toString(); + } + + String _getRecoveryErrorMessage(ErrorRecoveryException error) { + final attempts = error.attempts; + final originalError = getErrorMessage(error.originalError); + + return 'Failed after $attempts attempts: $originalError'; + } +} + +/// Configuration for retry behavior +class RetryConfig { + final String operationId; + final int maxRetries; + final Duration baseDelay; + final Duration cooldownPeriod; + final RetryStrategy retryStrategy; + final bool Function(dynamic error)? retryCondition; + + const RetryConfig({ + required this.operationId, + this.maxRetries = 3, + this.baseDelay = const Duration(seconds: 1), + this.cooldownPeriod = const Duration(seconds: 5), + this.retryStrategy = RetryStrategy.exponentialBackoff, + this.retryCondition, + }); + + static RetryConfig defaultConfig() => const RetryConfig( + operationId: 'default', + maxRetries: 3, + baseDelay: Duration(seconds: 1), + retryStrategy: RetryStrategy.exponentialBackoff, + ); + + static RetryConfig networkConfig() => const RetryConfig( + operationId: 'network', + maxRetries: 5, + baseDelay: Duration(milliseconds: 500), + retryStrategy: RetryStrategy.exponentialBackoff, + ); + + static RetryConfig chatConfig() => const RetryConfig( + operationId: 'chat', + maxRetries: 3, + baseDelay: Duration(seconds: 2), + retryStrategy: RetryStrategy.exponentialBackoff, + ); +} + +enum RetryStrategy { fixed, exponentialBackoff } + +/// Recovery action to execute between retries +abstract class RecoveryAction { + Future execute(dynamic error, int attempt); +} + +/// Reconnect to server recovery action +class ReconnectAction extends RecoveryAction { + final Future Function() reconnectFunction; + + ReconnectAction(this.reconnectFunction); + + @override + Future execute(dynamic error, int attempt) async { + if (attempt == 1) { + // Only try to reconnect on the first retry + await reconnectFunction(); + } + } +} + +/// Refresh token recovery action +class RefreshTokenAction extends RecoveryAction { + final Future Function() refreshFunction; + + RefreshTokenAction(this.refreshFunction); + + @override + Future execute(dynamic error, int attempt) async { + if (error is DioException && error.response?.statusCode == 401) { + await refreshFunction(); + } + } +} + +/// Clear cache recovery action +class ClearCacheAction extends RecoveryAction { + final Future Function() clearCacheFunction; + + ClearCacheAction(this.clearCacheFunction); + + @override + Future execute(dynamic error, int attempt) async { + if (attempt == 2) { + // Clear cache on second attempt + await clearCacheFunction(); + } + } +} + +/// Error recovery exception +class ErrorRecoveryException implements Exception { + final Exception originalError; + final int attempts; + + const ErrorRecoveryException(this.originalError, this.attempts); + + @override + String toString() => + 'ErrorRecoveryException: $originalError (after $attempts attempts)'; +} + +/// Providers +final errorRecoveryServiceProvider = Provider((ref) { + // This should use the same Dio instance as the API service + final dio = Dio(); // Replace with actual Dio provider + return ErrorRecoveryService(dio); +}); + +/// Error boundary widget for handling UI errors +class ErrorBoundary extends StatefulWidget { + final Widget child; + final Widget Function(Object error, VoidCallback retry)? errorBuilder; + final void Function(Object error, StackTrace stackTrace)? onError; + + const ErrorBoundary({ + super.key, + required this.child, + this.errorBuilder, + this.onError, + }); + + @override + State createState() => _ErrorBoundaryState(); +} + +class _ErrorBoundaryState extends State { + Object? error; + StackTrace? stackTrace; + + @override + Widget build(BuildContext context) { + if (error != null) { + return widget.errorBuilder?.call(error!, _retry) ?? + _buildDefaultErrorWidget(); + } + + return ErrorDetector( + onError: (error, stackTrace) { + setState(() { + this.error = error; + this.stackTrace = stackTrace; + }); + widget.onError?.call(error, stackTrace); + }, + child: widget.child, + ); + } + + Widget _buildDefaultErrorWidget() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: Spacing.xxxl, + color: context.conduitTheme.error, + ), + const SizedBox(height: Spacing.md), + const Text( + 'Something went wrong', + style: TextStyle( + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + error.toString(), + textAlign: TextAlign.center, + style: TextStyle(color: context.conduitTheme.textSecondary), + ), + const SizedBox(height: Spacing.md), + ElevatedButton(onPressed: _retry, child: const Text('Try Again')), + ], + ), + ); + } + + void _retry() { + setState(() { + error = null; + stackTrace = null; + }); + } +} + +/// Widget to detect and handle errors in child widgets +class ErrorDetector extends StatefulWidget { + final Widget child; + final void Function(Object error, StackTrace stackTrace) onError; + + const ErrorDetector({super.key, required this.child, required this.onError}); + + @override + State createState() => _ErrorDetectorState(); +} + +class _ErrorDetectorState extends State { + @override + Widget build(BuildContext context) { + return widget.child; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Set up error handling + FlutterError.onError = (details) { + widget.onError(details.exception, details.stack ?? StackTrace.current); + }; + } +} diff --git a/lib/core/services/focus_management_service.dart b/lib/core/services/focus_management_service.dart new file mode 100644 index 0000000..f1966a2 --- /dev/null +++ b/lib/core/services/focus_management_service.dart @@ -0,0 +1,408 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/semantics.dart'; + +/// Comprehensive focus management service for accessibility +class FocusManagementService { + static final Map _focusNodes = {}; + static final Map _disposedNodes = {}; + static FocusNode? _lastFocusedNode; + static final List _focusHistory = []; + + /// Register a focus node with a unique identifier + static FocusNode registerFocusNode( + String identifier, { + String? debugLabel, + FocusOnKeyEventCallback? onKeyEvent, + bool skipTraversal = false, + bool canRequestFocus = true, + }) { + // Check if node already exists + if (_focusNodes.containsKey(identifier)) { + return _focusNodes[identifier]!; + } + + // Create new focus node + final focusNode = FocusNode( + debugLabel: debugLabel ?? identifier, + onKeyEvent: onKeyEvent, + skipTraversal: skipTraversal, + canRequestFocus: canRequestFocus, + ); + + // Add listener to track focus changes + focusNode.addListener(() { + if (focusNode.hasFocus) { + _onFocusChanged(focusNode); + } + }); + + _focusNodes[identifier] = focusNode; + return focusNode; + } + + /// Get a registered focus node + static FocusNode? getFocusNode(String identifier) { + return _focusNodes[identifier]; + } + + /// Dispose a focus node + static void disposeFocusNode(String identifier) { + final node = _focusNodes.remove(identifier); + if (node != null) { + _disposedNodes[identifier] = node; + node.dispose(); + } + } + + /// Dispose all focus nodes + static void disposeAll() { + for (final node in _focusNodes.values) { + node.dispose(); + } + _focusNodes.clear(); + _focusHistory.clear(); + _lastFocusedNode = null; + } + + /// Request focus for a specific node + static void requestFocus(String identifier) { + final node = _focusNodes[identifier]; + if (node != null && node.canRequestFocus) { + node.requestFocus(); + HapticFeedback.selectionClick(); + } + } + + /// Unfocus current focus + static void unfocus( + BuildContext context, { + UnfocusDisposition disposition = UnfocusDisposition.scope, + }) { + FocusScope.of(context).unfocus(disposition: disposition); + } + + /// Move focus to next focusable element + static bool nextFocus(BuildContext context) { + return FocusScope.of(context).nextFocus(); + } + + /// Move focus to previous focusable element + static bool previousFocus(BuildContext context) { + return FocusScope.of(context).previousFocus(); + } + + /// Track focus changes + static void _onFocusChanged(FocusNode node) { + _lastFocusedNode = node; + _focusHistory.add(node); + + // Limit history size + if (_focusHistory.length > 10) { + _focusHistory.removeAt(0); + } + } + + /// Restore last focus + static void restoreLastFocus() { + if (_lastFocusedNode != null && _lastFocusedNode!.canRequestFocus) { + _lastFocusedNode!.requestFocus(); + } + } + + /// Get focus history + static List getFocusHistory() { + return List.unmodifiable(_focusHistory); + } + + /// Create a focus trap for modal dialogs + static Widget createFocusTrap({ + required Widget child, + bool autofocus = true, + }) { + return FocusScope(autofocus: autofocus, child: child); + } + + /// Create keyboard navigation handler + static FocusOnKeyEventCallback createKeyboardNavigationHandler({ + VoidCallback? onEnter, + VoidCallback? onEscape, + VoidCallback? onTab, + VoidCallback? onArrowUp, + VoidCallback? onArrowDown, + VoidCallback? onArrowLeft, + VoidCallback? onArrowRight, + }) { + return (FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + + if (key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.numpadEnter) { + onEnter?.call(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.escape) { + onEscape?.call(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.tab) { + onTab?.call(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowUp) { + onArrowUp?.call(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowDown) { + onArrowDown?.call(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowLeft) { + onArrowLeft?.call(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowRight) { + onArrowRight?.call(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + }; + } +} + +/// Focus manager widget that manages focus for its children +class FocusManager extends StatefulWidget { + final Widget child; + final bool autofocus; + final bool trapFocus; + final FocusOnKeyEventCallback? onKeyEvent; + + const FocusManager({ + super.key, + required this.child, + this.autofocus = false, + this.trapFocus = false, + this.onKeyEvent, + }); + + @override + State createState() => _FocusManagerState(); +} + +class _FocusManagerState extends State { + late FocusScopeNode _focusScopeNode; + + @override + void initState() { + super.initState(); + _focusScopeNode = FocusScopeNode( + debugLabel: 'FocusManager', + onKeyEvent: widget.onKeyEvent, + ); + } + + @override + void dispose() { + _focusScopeNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child = FocusScope( + node: _focusScopeNode, + autofocus: widget.autofocus, + child: widget.child, + ); + + if (widget.trapFocus) { + child = FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: child, + ); + } + + return child; + } +} + +/// Accessible form field with proper focus management +class AccessibleFormField extends StatefulWidget { + final String label; + final String? hint; + final TextEditingController controller; + final String? Function(String?)? validator; + final TextInputType? keyboardType; + final bool obscureText; + final bool autofocus; + final String? semanticLabel; + final String? errorSemanticLabel; + final ValueChanged? onChanged; + final VoidCallback? onEditingComplete; + final ValueChanged? onSubmitted; + final List? inputFormatters; + final int? maxLines; + final int? maxLength; + final bool enabled; + final Widget? suffixIcon; + final Widget? prefixIcon; + final FocusNode? focusNode; + + const AccessibleFormField({ + super.key, + required this.label, + this.hint, + required this.controller, + this.validator, + this.keyboardType, + this.obscureText = false, + this.autofocus = false, + this.semanticLabel, + this.errorSemanticLabel, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.inputFormatters, + this.maxLines = 1, + this.maxLength, + this.enabled = true, + this.suffixIcon, + this.prefixIcon, + this.focusNode, + }); + + @override + State createState() => _AccessibleFormFieldState(); +} + +class _AccessibleFormFieldState extends State { + late FocusNode _focusNode; + String? _errorText; + bool _hasFocus = false; + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(debugLabel: widget.label); + _focusNode.addListener(_onFocusChanged); + } + + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + void _onFocusChanged() { + setState(() { + _hasFocus = _focusNode.hasFocus; + }); + + // Announce focus change for screen readers + if (_hasFocus) { + final announcement = + widget.semanticLabel ?? + '${widget.label} text field. ${widget.hint ?? ''}'; + SemanticsService.announce(announcement, TextDirection.ltr); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Semantics( + label: widget.semanticLabel ?? widget.label, + hint: widget.hint, + textField: true, + enabled: widget.enabled, + focusable: true, + focused: _hasFocus, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + widget.label, + style: theme.textTheme.bodyMedium?.copyWith( + color: _hasFocus + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + fontWeight: _hasFocus ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + + // Text field + TextFormField( + controller: widget.controller, + focusNode: _focusNode, + validator: (value) { + final error = widget.validator?.call(value); + setState(() { + _errorText = error; + }); + + // Announce error for screen readers + if (error != null) { + final errorAnnouncement = + widget.errorSemanticLabel ?? 'Error: $error'; + SemanticsService.announce(errorAnnouncement, TextDirection.ltr); + } + + return error; + }, + keyboardType: widget.keyboardType, + obscureText: widget.obscureText, + autofocus: widget.autofocus, + onChanged: widget.onChanged, + onEditingComplete: widget.onEditingComplete, + onFieldSubmitted: widget.onSubmitted, + inputFormatters: widget.inputFormatters, + maxLines: widget.maxLines, + maxLength: widget.maxLength, + enabled: widget.enabled, + decoration: InputDecoration( + hintText: widget.hint, + errorText: _errorText, + suffixIcon: widget.suffixIcon, + prefixIcon: widget.prefixIcon, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.error, + width: 2, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/core/services/input_validation_service.dart b/lib/core/services/input_validation_service.dart new file mode 100644 index 0000000..dd51481 --- /dev/null +++ b/lib/core/services/input_validation_service.dart @@ -0,0 +1,457 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Comprehensive input validation service +class InputValidationService { + // Email regex pattern + static final RegExp _emailRegex = RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + ); + + // Strong password regex (min 8 chars, 1 upper, 1 lower, 1 number, 1 special) + static final RegExp _strongPasswordRegex = RegExp( + r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$', + ); + + /// Validate email address + static String? validateEmail(String? value) { + if (value == null || value.isEmpty) { + return 'Email is required'; + } + + final trimmed = value.trim(); + if (!_emailRegex.hasMatch(trimmed)) { + return 'Please enter a valid email address'; + } + + return null; + } + + /// Validate URL + static String? validateUrl(String? value, {bool required = true}) { + if (value == null || value.isEmpty) { + return required ? 'URL is required' : null; + } + + final trimmed = value.trim(); + + // Add protocol if missing + String urlToValidate = trimmed; + if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) { + urlToValidate = 'https://$trimmed'; + } + + try { + final uri = Uri.parse(urlToValidate); + if (!uri.hasScheme || !uri.hasAuthority) { + return 'Please enter a valid URL'; + } + } catch (e) { + return 'Please enter a valid URL'; + } + + return null; + } + + /// Validate password strength + static String? validatePassword(String? value, {bool checkStrength = true}) { + if (value == null || value.isEmpty) { + return 'Password is required'; + } + + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + + if (checkStrength && !_strongPasswordRegex.hasMatch(value)) { + return 'Password must contain uppercase, lowercase, number, and special character'; + } + + return null; + } + + /// Validate confirm password + static String? validateConfirmPassword(String? value, String password) { + if (value == null || value.isEmpty) { + return 'Please confirm your password'; + } + + if (value != password) { + return 'Passwords do not match'; + } + + return null; + } + + /// Validate required field + static String? validateRequired( + String? value, { + String fieldName = 'This field', + }) { + if (value == null || value.trim().isEmpty) { + return '$fieldName is required'; + } + return null; + } + + /// Validate minimum length + static String? validateMinLength( + String? value, + int minLength, { + String fieldName = 'This field', + }) { + if (value == null || value.isEmpty) { + return '$fieldName is required'; + } + + if (value.length < minLength) { + return '$fieldName must be at least $minLength characters'; + } + + return null; + } + + /// Validate maximum length + static String? validateMaxLength( + String? value, + int maxLength, { + String fieldName = 'This field', + }) { + if (value != null && value.length > maxLength) { + return '$fieldName must be at most $maxLength characters'; + } + + return null; + } + + /// Validate numeric input + static String? validateNumber( + String? value, { + double? min, + double? max, + bool allowDecimal = true, + bool required = true, + }) { + if (value == null || value.isEmpty) { + return required ? 'Number is required' : null; + } + + final number = allowDecimal ? double.tryParse(value) : int.tryParse(value); + + if (number == null) { + return allowDecimal + ? 'Please enter a valid number' + : 'Please enter a whole number'; + } + + if (min != null && number < min) { + return 'Value must be at least $min'; + } + + if (max != null && number > max) { + return 'Value must be at most $max'; + } + + return null; + } + + /// Validate phone number + static String? validatePhoneNumber(String? value, {bool required = true}) { + if (value == null || value.isEmpty) { + return required ? 'Phone number is required' : null; + } + + // Remove all non-digits + final digitsOnly = value.replaceAll(RegExp(r'\D'), ''); + + if (digitsOnly.length < 10) { + return 'Please enter a valid phone number'; + } + + return null; + } + + /// Validate alphanumeric input + static String? validateAlphanumeric( + String? value, { + bool allowSpaces = false, + bool required = true, + String fieldName = 'This field', + }) { + if (value == null || value.isEmpty) { + return required ? '$fieldName is required' : null; + } + + final pattern = allowSpaces ? r'^[a-zA-Z0-9\s]+$' : r'^[a-zA-Z0-9]+$'; + if (!RegExp(pattern).hasMatch(value)) { + return allowSpaces + ? '$fieldName can only contain letters, numbers, and spaces' + : '$fieldName can only contain letters and numbers'; + } + + return null; + } + + /// Validate username + static String? validateUsername(String? value) { + if (value == null || value.isEmpty) { + return 'Username is required'; + } + + if (value.length < 3) { + return 'Username must be at least 3 characters'; + } + + if (value.length > 20) { + return 'Username must be at most 20 characters'; + } + + if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) { + return 'Username can only contain letters, numbers, and underscores'; + } + + return null; + } + + /// Validate email or username (flexible login) + static String? validateEmailOrUsername(String? value) { + if (value == null || value.isEmpty) { + return 'Email or username is required'; + } + + final trimmed = value.trim(); + + // If it contains @ symbol, validate as email + if (trimmed.contains('@')) { + return validateEmail(value); + } + + // Otherwise validate as username + return validateUsername(value); + } + + /// Sanitize input to prevent XSS + static String sanitizeInput(String input) { + return input + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('/', '/'); + } + + /// Create input formatter for numeric input + static List numericInputFormatters({ + bool allowDecimal = true, + bool allowNegative = false, + }) { + return [ + FilteringTextInputFormatter.allow( + RegExp( + allowDecimal + ? (allowNegative ? r'[0-9.-]' : r'[0-9.]') + : (allowNegative ? r'[0-9-]' : r'[0-9]'), + ), + ), + ]; + } + + /// Create input formatter for alphanumeric input + static List alphanumericInputFormatters({ + bool allowSpaces = false, + }) { + return [ + FilteringTextInputFormatter.allow( + RegExp(allowSpaces ? r'[a-zA-Z0-9\s]' : r'[a-zA-Z0-9]'), + ), + ]; + } + + /// Create input formatter for phone number + static List phoneNumberFormatters() { + return [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(15), + _PhoneNumberFormatter(), + ]; + } + + /// Validate file size + static String? validateFileSize(int sizeInBytes, {int maxSizeInMB = 10}) { + final maxSizeInBytes = maxSizeInMB * 1024 * 1024; + if (sizeInBytes > maxSizeInBytes) { + return 'File size must be less than ${maxSizeInMB}MB'; + } + return null; + } + + /// Validate file extension + static String? validateFileExtension( + String fileName, + List allowedExtensions, + ) { + final extension = fileName.split('.').last.toLowerCase(); + if (!allowedExtensions.contains(extension)) { + return 'File type not allowed. Allowed types: ${allowedExtensions.join(', ')}'; + } + return null; + } + + /// Composite validator that runs multiple validators + static String? Function(String?) combine( + List validators, + ) { + return (String? value) { + for (final validator in validators) { + final result = validator(value); + if (result != null) { + return result; + } + } + return null; + }; + } +} + +/// Custom phone number formatter +class _PhoneNumberFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final text = newValue.text; + + if (text.length <= 3) { + return newValue; + } + + if (text.length <= 6) { + final newText = '(${text.substring(0, 3)}) ${text.substring(3)}'; + return TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + ); + } + + if (text.length <= 10) { + final newText = + '(${text.substring(0, 3)}) ${text.substring(3, 6)}-${text.substring(6)}'; + return TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + ); + } + + final newText = + '(${text.substring(0, 3)}) ${text.substring(3, 6)}-${text.substring(6, 10)}'; + return TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + ); + } +} + +/// Form field wrapper with validation +class ValidatedFormField extends StatefulWidget { + final String label; + final String? hint; + final TextEditingController controller; + final String? Function(String?) validator; + final List? inputFormatters; + final TextInputType? keyboardType; + final bool obscureText; + final Widget? suffixIcon; + final bool autofocus; + final void Function(String)? onChanged; + final void Function(String)? onFieldSubmitted; + final FocusNode? focusNode; + final int? maxLines; + final bool enabled; + + const ValidatedFormField({ + super.key, + required this.label, + this.hint, + required this.controller, + required this.validator, + this.inputFormatters, + this.keyboardType, + this.obscureText = false, + this.suffixIcon, + this.autofocus = false, + this.onChanged, + this.onFieldSubmitted, + this.focusNode, + this.maxLines = 1, + this.enabled = true, + }); + + @override + State createState() => _ValidatedFormFieldState(); +} + +class _ValidatedFormFieldState extends State { + String? _errorText; + bool _hasInteracted = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_validate); + } + + @override + void dispose() { + widget.controller.removeListener(_validate); + super.dispose(); + } + + void _validate() { + if (!_hasInteracted) return; + + final error = widget.validator(widget.controller.text); + if (error != _errorText) { + setState(() { + _errorText = error; + }); + } + } + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: widget.controller, + focusNode: widget.focusNode, + validator: (value) { + setState(() { + _hasInteracted = true; + }); + return widget.validator(value); + }, + inputFormatters: widget.inputFormatters, + keyboardType: widget.keyboardType, + obscureText: widget.obscureText, + autofocus: widget.autofocus, + maxLines: widget.maxLines, + enabled: widget.enabled, + onChanged: (value) { + if (!_hasInteracted) { + setState(() { + _hasInteracted = true; + }); + } + _validate(); + widget.onChanged?.call(value); + }, + onFieldSubmitted: widget.onFieldSubmitted, + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hint, + errorText: _errorText, + suffixIcon: widget.suffixIcon, + border: const OutlineInputBorder(), + ), + ); + } +} diff --git a/lib/core/services/navigation_service.dart b/lib/core/services/navigation_service.dart new file mode 100644 index 0000000..bf50b66 --- /dev/null +++ b/lib/core/services/navigation_service.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +// ThemedDialogs handles theming; no direct use of extensions here +import '../../features/chat/views/chat_page.dart'; +import '../../features/auth/views/connect_signin_page.dart'; +import '../../features/settings/views/searchable_settings_page.dart'; +import '../../features/profile/views/profile_page.dart'; +import '../../features/files/views/files_page.dart'; +import '../../features/chat/views/conversation_search_page.dart'; +import '../../shared/widgets/themed_dialogs.dart'; + +import '../../features/navigation/views/chats_list_page.dart'; + +/// Centralized navigation service to handle all routing logic +/// Prevents navigation stack issues and memory leaks +class NavigationService { + static final GlobalKey navigatorKey = + GlobalKey(); + + static NavigatorState? get navigator => navigatorKey.currentState; + static BuildContext? get context => navigatorKey.currentContext; + + // Navigation stack tracking for analytics and debugging + static final List _navigationStack = []; + static List get navigationStack => + List.unmodifiable(_navigationStack); + + // Prevent duplicate navigation + static String? _currentRoute; + static bool _isNavigating = false; + static DateTime? _lastNavigationTime; + + /// Navigate to a named route with optional arguments + static Future navigateTo( + String routeName, { + Object? arguments, + bool replace = false, + bool clearStack = false, + }) async { + // Only block if we're already navigating to the exact same route + // Allow navigation to different routes even if currently navigating + if (_isNavigating && _currentRoute == routeName) { + debugPrint('Navigation blocked: Already navigating to same route'); + return null; + } + + // Prevent rapid successive navigation attempts + final now = DateTime.now(); + if (_lastNavigationTime != null && + now.difference(_lastNavigationTime!).inMilliseconds < 300) { + debugPrint('Navigation blocked: Too rapid navigation attempts'); + return null; + } + + _isNavigating = true; + + try { + // Add haptic feedback for navigation + HapticFeedback.lightImpact(); + + // Track navigation + if (!replace && !clearStack) { + _navigationStack.add(routeName); + } + _currentRoute = routeName; + + if (clearStack) { + _navigationStack.clear(); + _navigationStack.add(routeName); + return await navigator?.pushNamedAndRemoveUntil( + routeName, + (route) => false, + arguments: arguments, + ); + } else if (replace) { + if (_navigationStack.isNotEmpty) { + _navigationStack.removeLast(); + } + _navigationStack.add(routeName); + return await navigator?.pushReplacementNamed( + routeName, + arguments: arguments, + ); + } else { + return await navigator?.pushNamed(routeName, arguments: arguments); + } + } catch (e) { + debugPrint('Navigation error: $e'); + rethrow; + } finally { + _isNavigating = false; + _lastNavigationTime = DateTime.now(); + } + } + + /// Navigate back with optional result + static void goBack([T? result]) { + if (navigator?.canPop() == true) { + HapticFeedback.lightImpact(); + if (_navigationStack.isNotEmpty) { + _navigationStack.removeLast(); + } + _currentRoute = _navigationStack.isEmpty ? null : _navigationStack.last; + navigator?.pop(result); + } + } + + /// Check if can navigate back + static bool canGoBack() { + return navigator?.canPop() == true; + } + + /// Show confirmation dialog before navigation + static Future confirmNavigation({ + required String title, + required String message, + String confirmText = 'Continue', + String cancelText = 'Cancel', + }) async { + if (context == null) return false; + + final result = await ThemedDialogs.confirm( + context!, + title: title, + message: message, + confirmText: confirmText, + cancelText: cancelText, + barrierDismissible: false, + ); + + return result; + } + + // Removed tabbed main navigation + + /// Navigate to chat + static Future navigateToChat({String? conversationId}) { + return navigateTo( + Routes.chat, + arguments: {'conversationId': conversationId}, + replace: true, + ); + } + + /// Navigate to login + static Future navigateToLogin() { + return navigateTo(Routes.login, clearStack: true); + } + + /// Navigate to settings + static Future navigateToSettings() { + return navigateTo(Routes.settings); + } + + /// Navigate to profile + static Future navigateToProfile() { + return navigateTo(Routes.profile); + } + + /// Navigate to server connection + static Future navigateToServerConnection() { + return navigateTo(Routes.serverConnection); + } + + /// Navigate to search + static Future navigateToSearch() { + return navigateTo(Routes.search); + } + + /// Navigate to chats list + static Future navigateToChatsList() { + return navigateTo(Routes.chatsList); + } + + /// Clear navigation stack (useful for logout) + static void clearNavigationStack() { + _navigationStack.clear(); + _currentRoute = null; + } + + /// Set current route (useful for initial app state) + static void setCurrentRoute(String routeName) { + _currentRoute = routeName; + if (!_navigationStack.contains(routeName)) { + _navigationStack.add(routeName); + } + } + + /// Generate routes + static Route? generateRoute(RouteSettings settings) { + Widget page; + + switch (settings.name) { + // Removed tabbed main navigation + + case Routes.chat: + page = const ChatPage(); + break; + + case Routes.login: + page = const ConnectAndSignInPage(); + break; + + case Routes.settings: + page = const SearchableSettingsPage(); + break; + + case Routes.profile: + page = const ProfilePage(); + break; + + case Routes.serverConnection: + page = const ConnectAndSignInPage(); + break; + + case Routes.search: + page = const ConversationSearchPage(); + break; + + case Routes.files: + page = const FilesPage(); + break; + + case Routes.chatsList: + page = const ChatsListPage(); + break; + + // Removed navigation drawer route + + default: + page = Scaffold( + body: Center(child: Text('Route not found: ${settings.name}')), + ); + } + + return MaterialPageRoute(builder: (_) => page, settings: settings); + } +} + +/// Route names +class Routes { + static const String chat = '/chat'; + static const String login = '/login'; + static const String settings = '/settings'; + static const String profile = '/profile'; + static const String serverConnection = '/server-connection'; + static const String search = '/search'; + static const String files = '/files'; + static const String chatsList = '/chats-list'; +} diff --git a/lib/core/services/navigation_state_service.dart b/lib/core/services/navigation_state_service.dart new file mode 100644 index 0000000..c96b7a8 --- /dev/null +++ b/lib/core/services/navigation_state_service.dart @@ -0,0 +1,427 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Navigation state data model +class NavigationState { + final String routeName; + final Map arguments; + final DateTime timestamp; + final String? conversationId; + final int? tabIndex; + + NavigationState({ + required this.routeName, + this.arguments = const {}, + DateTime? timestamp, + this.conversationId, + this.tabIndex, + }) : timestamp = timestamp ?? DateTime.now(); + + Map toJson() => { + 'routeName': routeName, + 'arguments': arguments, + 'timestamp': timestamp.toIso8601String(), + 'conversationId': conversationId, + 'tabIndex': tabIndex, + }; + + factory NavigationState.fromJson(Map json) { + return NavigationState( + routeName: json['routeName'] ?? '/', + arguments: json['arguments'] ?? {}, + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), + conversationId: json['conversationId'], + tabIndex: json['tabIndex'], + ); + } +} + +/// Service to manage navigation state preservation and restoration +class NavigationStateService { + static final NavigationStateService _instance = + NavigationStateService._internal(); + factory NavigationStateService() => _instance; + NavigationStateService._internal(); + + static const String _navigationStackKey = 'navigation_stack'; + static const String _currentStateKey = 'current_navigation_state'; + static const String _deepLinkStateKey = 'deep_link_state'; + + SharedPreferences? _prefs; + final List _navigationStack = []; + NavigationState? _currentState; + final ValueNotifier _stateNotifier = ValueNotifier(null); + + /// Initialize the service + Future initialize() async { + try { + _prefs = await SharedPreferences.getInstance(); + await _loadNavigationState(); + debugPrint('DEBUG: NavigationStateService initialized'); + } catch (e) { + debugPrint('ERROR: Failed to initialize NavigationStateService: $e'); + } + } + + /// Get current navigation state as a ValueNotifier for listening to changes + ValueNotifier get stateNotifier => _stateNotifier; + + /// Get current navigation state + NavigationState? get currentState => _currentState; + + /// Get navigation stack + List get navigationStack => + List.unmodifiable(_navigationStack); + + /// Push a new navigation state + Future pushState({ + required String routeName, + Map arguments = const {}, + String? conversationId, + int? tabIndex, + }) async { + try { + final state = NavigationState( + routeName: routeName, + arguments: arguments, + conversationId: conversationId, + tabIndex: tabIndex, + ); + + _navigationStack.add(state); + _currentState = state; + _stateNotifier.value = state; + + await _saveNavigationState(); + + debugPrint('DEBUG: Navigation state pushed - ${state.routeName}'); + } catch (e) { + debugPrint('ERROR: Failed to push navigation state: $e'); + } + } + + /// Pop the last navigation state + Future popState() async { + try { + if (_navigationStack.isEmpty) return null; + + final poppedState = _navigationStack.removeLast(); + _currentState = _navigationStack.isNotEmpty + ? _navigationStack.last + : null; + _stateNotifier.value = _currentState; + + await _saveNavigationState(); + + debugPrint('DEBUG: Navigation state popped - ${poppedState.routeName}'); + return poppedState; + } catch (e) { + debugPrint('ERROR: Failed to pop navigation state: $e'); + return null; + } + } + + /// Update current state with new information + Future updateCurrentState({ + String? conversationId, + int? tabIndex, + Map? additionalArgs, + }) async { + try { + if (_currentState == null) return; + + final updatedArgs = { + ..._currentState!.arguments, + if (additionalArgs != null) ...additionalArgs, + }; + + final updatedState = NavigationState( + routeName: _currentState!.routeName, + arguments: updatedArgs, + conversationId: conversationId ?? _currentState!.conversationId, + tabIndex: tabIndex ?? _currentState!.tabIndex, + timestamp: _currentState!.timestamp, + ); + + // Update both current state and last item in stack + _currentState = updatedState; + if (_navigationStack.isNotEmpty) { + _navigationStack[_navigationStack.length - 1] = updatedState; + } + + _stateNotifier.value = updatedState; + await _saveNavigationState(); + + debugPrint('DEBUG: Navigation state updated'); + } catch (e) { + debugPrint('ERROR: Failed to update navigation state: $e'); + } + } + + /// Clear navigation stack but preserve current state + Future clearStack() async { + try { + _navigationStack.clear(); + if (_currentState != null) { + _navigationStack.add(_currentState!); + } + await _saveNavigationState(); + debugPrint('DEBUG: Navigation stack cleared'); + } catch (e) { + debugPrint('ERROR: Failed to clear navigation stack: $e'); + } + } + + /// Replace entire navigation stack + Future replaceStack(List newStack) async { + try { + _navigationStack.clear(); + _navigationStack.addAll(newStack); + _currentState = newStack.isNotEmpty ? newStack.last : null; + _stateNotifier.value = _currentState; + + await _saveNavigationState(); + debugPrint( + 'DEBUG: Navigation stack replaced with ${newStack.length} states', + ); + } catch (e) { + debugPrint('ERROR: Failed to replace navigation stack: $e'); + } + } + + /// Handle deep link by preserving navigation context + Future handleDeepLink({ + required String routeName, + Map arguments = const {}, + String? conversationId, + bool preserveStack = true, + }) async { + try { + // Save deep link state for restoration + final deepLinkState = NavigationState( + routeName: routeName, + arguments: arguments, + conversationId: conversationId, + ); + + await _saveDeepLinkState(deepLinkState); + + if (preserveStack) { + // Add to existing stack instead of replacing + await pushState( + routeName: routeName, + arguments: arguments, + conversationId: conversationId, + ); + } else { + // Replace stack with deep link + await replaceStack([deepLinkState]); + } + + debugPrint('DEBUG: Deep link handled - $routeName'); + } catch (e) { + debugPrint('ERROR: Failed to handle deep link: $e'); + } + } + + /// Get the conversation context from current navigation state + String? getConversationContext() { + return _currentState?.conversationId; + } + + /// Get the current tab index + int? getCurrentTabIndex() { + return _currentState?.tabIndex; + } + + /// Generate breadcrumb navigation based on current stack + List generateBreadcrumbs() { + final breadcrumbs = []; + + for (int i = 0; i < _navigationStack.length; i++) { + final state = _navigationStack[i]; + final isLast = i == _navigationStack.length - 1; + + breadcrumbs.add( + NavigationBreadcrumb( + title: _getRouteTitle(state.routeName), + routeName: state.routeName, + arguments: state.arguments, + isActive: isLast, + canNavigateBack: i > 0, + ), + ); + } + + return breadcrumbs; + } + + /// Check if we can navigate back + bool canGoBack() { + return _navigationStack.length > 1; + } + + /// Get previous state without popping + NavigationState? getPreviousState() { + if (_navigationStack.length < 2) return null; + return _navigationStack[_navigationStack.length - 2]; + } + + /// Restore navigation state on app startup + Future restoreNavigationState(NavigatorState navigator) async { + try { + await _loadNavigationState(); + + if (_currentState != null) { + // Attempt to restore to the last known state + debugPrint( + 'DEBUG: Restoring navigation to ${_currentState!.routeName}', + ); + + // This would need to be implemented based on your routing setup + // navigator.pushNamedAndRemoveUntil( + // _currentState!.routeName, + // (route) => false, + // arguments: _currentState!.arguments, + // ); + } + } catch (e) { + debugPrint('ERROR: Failed to restore navigation state: $e'); + } + } + + /// Clear all navigation state + Future clearAll() async { + try { + _navigationStack.clear(); + _currentState = null; + _stateNotifier.value = null; + + await _prefs?.remove(_navigationStackKey); + await _prefs?.remove(_currentStateKey); + await _prefs?.remove(_deepLinkStateKey); + + debugPrint('DEBUG: All navigation state cleared'); + } catch (e) { + debugPrint('ERROR: Failed to clear navigation state: $e'); + } + } + + /// Save navigation state to persistent storage + Future _saveNavigationState() async { + if (_prefs == null) return; + + try { + // Save navigation stack + final stackJson = _navigationStack + .map((state) => state.toJson()) + .toList(); + await _prefs!.setString(_navigationStackKey, jsonEncode(stackJson)); + + // Save current state + if (_currentState != null) { + await _prefs!.setString( + _currentStateKey, + jsonEncode(_currentState!.toJson()), + ); + } else { + await _prefs!.remove(_currentStateKey); + } + } catch (e) { + debugPrint('ERROR: Failed to save navigation state: $e'); + } + } + + /// Load navigation state from persistent storage + Future _loadNavigationState() async { + if (_prefs == null) return; + + try { + // Load navigation stack + final stackJsonString = _prefs!.getString(_navigationStackKey); + if (stackJsonString != null) { + final stackJson = jsonDecode(stackJsonString) as List; + _navigationStack.clear(); + for (final stateJson in stackJson) { + if (stateJson is Map) { + _navigationStack.add(NavigationState.fromJson(stateJson)); + } + } + } + + // Load current state + final currentStateJsonString = _prefs!.getString(_currentStateKey); + if (currentStateJsonString != null) { + final currentStateJson = + jsonDecode(currentStateJsonString) as Map; + _currentState = NavigationState.fromJson(currentStateJson); + _stateNotifier.value = _currentState; + } + + debugPrint( + 'DEBUG: Navigation state loaded - ${_navigationStack.length} states', + ); + } catch (e) { + debugPrint('ERROR: Failed to load navigation state: $e'); + // Clear corrupted state + await clearAll(); + } + } + + /// Save deep link state for restoration + Future _saveDeepLinkState(NavigationState state) async { + if (_prefs == null) return; + + try { + await _prefs!.setString(_deepLinkStateKey, jsonEncode(state.toJson())); + } catch (e) { + debugPrint('ERROR: Failed to save deep link state: $e'); + } + } + + /// Get user-friendly title for route name + String _getRouteTitle(String routeName) { + switch (routeName) { + case '/': + case '/home': + return 'Home'; + case '/chat': + return 'Chat'; + case '/settings': + return 'Settings'; + case '/profile': + return 'Profile'; + case '/conversations': + return 'Conversations'; + default: + // Convert route name to title case + return routeName + .replaceAll('/', '') + .split('_') + .map( + (word) => word.isNotEmpty + ? '${word[0].toUpperCase()}${word.substring(1)}' + : '', + ) + .join(' '); + } + } +} + +/// Breadcrumb navigation item +class NavigationBreadcrumb { + final String title; + final String routeName; + final Map arguments; + final bool isActive; + final bool canNavigateBack; + + NavigationBreadcrumb({ + required this.title, + required this.routeName, + required this.arguments, + required this.isActive, + required this.canNavigateBack, + }); +} diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart new file mode 100644 index 0000000..884f67b --- /dev/null +++ b/lib/core/services/optimized_storage_service.dart @@ -0,0 +1,375 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'secure_credential_storage.dart'; +import '../models/server_config.dart'; +import '../models/conversation.dart'; + +/// Optimized storage service with single secure storage implementation +/// Eliminates dual storage overhead and improves performance +class OptimizedStorageService { + final SharedPreferences _prefs; + final SecureCredentialStorage _secureCredentialStorage; + + OptimizedStorageService({ + required FlutterSecureStorage secureStorage, + required SharedPreferences prefs, + }) : _prefs = prefs, + _secureCredentialStorage = SecureCredentialStorage(); + + // Optimized key names with versioning + static const String _authTokenKey = 'auth_token_v3'; + static const String _activeServerIdKey = 'active_server_id'; + static const String _rememberCredentialsKey = 'remember_credentials'; + static const String _themeModeKey = 'theme_mode'; + static const String _localConversationsKey = 'local_conversations'; + static const String _onboardingSeenKey = 'onboarding_seen_v1'; + static const String _reviewerModeKey = 'reviewer_mode_v1'; + + // Cache for frequently accessed data + final Map _cache = {}; + static const Duration _cacheTimeout = Duration(minutes: 5); + final Map _cacheTimestamps = {}; + + /// Auth Token Management (Optimized with caching) + Future saveAuthToken(String token) async { + try { + await _secureCredentialStorage.saveAuthToken(token); + _cache[_authTokenKey] = token; + _cacheTimestamps[_authTokenKey] = DateTime.now(); + debugPrint('DEBUG: Auth token saved and cached'); + } catch (e) { + debugPrint('ERROR: Failed to save auth token: $e'); + rethrow; + } + } + + Future getAuthToken() async { + // Check cache first + if (_isCacheValid(_authTokenKey)) { + final cachedToken = _cache[_authTokenKey] as String?; + if (cachedToken != null) { + debugPrint('DEBUG: Using cached auth token'); + return cachedToken; + } + } + + try { + final token = await _secureCredentialStorage.getAuthToken(); + if (token != null) { + _cache[_authTokenKey] = token; + _cacheTimestamps[_authTokenKey] = DateTime.now(); + } + return token; + } catch (e) { + debugPrint('ERROR: Failed to retrieve auth token: $e'); + return null; + } + } + + Future deleteAuthToken() async { + try { + await _secureCredentialStorage.deleteAuthToken(); + _cache.remove(_authTokenKey); + _cacheTimestamps.remove(_authTokenKey); + debugPrint('DEBUG: Auth token deleted and cache cleared'); + } catch (e) { + debugPrint('ERROR: Failed to delete auth token: $e'); + } + } + + /// Credential Management (Single storage implementation) + Future saveCredentials({ + required String serverId, + required String username, + required String password, + }) async { + try { + await _secureCredentialStorage.saveCredentials( + serverId: serverId, + username: username, + password: password, + ); + + // Cache the fact that credentials exist (not the credentials themselves) + _cache['has_credentials'] = true; + _cacheTimestamps['has_credentials'] = DateTime.now(); + + debugPrint('DEBUG: Credentials saved via optimized storage'); + } catch (e) { + debugPrint('ERROR: Failed to save credentials: $e'); + rethrow; + } + } + + Future?> getSavedCredentials() async { + try { + // Use single storage implementation - no fallback needed + final credentials = await _secureCredentialStorage.getSavedCredentials(); + + // Update cache flag + _cache['has_credentials'] = credentials != null; + _cacheTimestamps['has_credentials'] = DateTime.now(); + + return credentials; + } catch (e) { + debugPrint('ERROR: Failed to retrieve credentials: $e'); + return null; + } + } + + Future deleteSavedCredentials() async { + try { + await _secureCredentialStorage.deleteSavedCredentials(); + _cache.remove('has_credentials'); + _cacheTimestamps.remove('has_credentials'); + debugPrint('DEBUG: Credentials deleted via optimized storage'); + } catch (e) { + debugPrint('ERROR: Failed to delete credentials: $e'); + } + } + + /// Quick check if credentials exist (uses cache) + Future hasCredentials() async { + if (_isCacheValid('has_credentials')) { + return _cache['has_credentials'] == true; + } + + final credentials = await getSavedCredentials(); + return credentials != null; + } + + /// Remember Credentials Flag + Future setRememberCredentials(bool remember) async { + await _prefs.setBool(_rememberCredentialsKey, remember); + } + + bool getRememberCredentials() { + return _prefs.getBool(_rememberCredentialsKey) ?? false; + } + + /// Server Configuration (Optimized) + Future saveServerConfigs(List configs) async { + try { + final jsonString = jsonEncode(configs.map((c) => c.toJson()).toList()); + await _secureCredentialStorage.saveServerConfigs(jsonString); + + // Cache config count for quick checks + _cache['server_config_count'] = configs.length; + _cacheTimestamps['server_config_count'] = DateTime.now(); + + debugPrint('DEBUG: Server configs saved (${configs.length} configs)'); + } catch (e) { + debugPrint('ERROR: Failed to save server configs: $e'); + rethrow; + } + } + + Future> getServerConfigs() async { + try { + final jsonString = await _secureCredentialStorage.getServerConfigs(); + if (jsonString == null || jsonString.isEmpty) { + _cache['server_config_count'] = 0; + _cacheTimestamps['server_config_count'] = DateTime.now(); + return []; + } + + final decoded = jsonDecode(jsonString) as List; + final configs = decoded + .map((item) => ServerConfig.fromJson(item)) + .toList(); + + // Update cache + _cache['server_config_count'] = configs.length; + _cacheTimestamps['server_config_count'] = DateTime.now(); + + return configs; + } catch (e) { + debugPrint('ERROR: Failed to retrieve server configs: $e'); + return []; + } + } + + /// Active Server Management + Future setActiveServerId(String? serverId) async { + if (serverId != null) { + await _prefs.setString(_activeServerIdKey, serverId); + } else { + await _prefs.remove(_activeServerIdKey); + } + + // Update cache + _cache[_activeServerIdKey] = serverId; + _cacheTimestamps[_activeServerIdKey] = DateTime.now(); + } + + Future getActiveServerId() async { + // Check cache first + if (_isCacheValid(_activeServerIdKey)) { + return _cache[_activeServerIdKey] as String?; + } + + final serverId = _prefs.getString(_activeServerIdKey); + _cache[_activeServerIdKey] = serverId; + _cacheTimestamps[_activeServerIdKey] = DateTime.now(); + + return serverId; + } + + /// Theme Management + String? getThemeMode() { + return _prefs.getString(_themeModeKey); + } + + Future setThemeMode(String mode) async { + await _prefs.setString(_themeModeKey, mode); + } + + /// Onboarding + Future getOnboardingSeen() async { + return _prefs.getBool(_onboardingSeenKey) ?? false; + } + + Future setOnboardingSeen(bool seen) async { + await _prefs.setBool(_onboardingSeenKey, seen); + } + + /// Reviewer mode (persisted) + Future getReviewerMode() async { + return _prefs.getBool(_reviewerModeKey) ?? false; + } + + Future setReviewerMode(bool enabled) async { + await _prefs.setBool(_reviewerModeKey, enabled); + } + + /// Local Conversations (Optimized with compression) + Future> getLocalConversations() async { + try { + final jsonString = _prefs.getString(_localConversationsKey); + if (jsonString == null || jsonString.isEmpty) return []; + + final decoded = jsonDecode(jsonString) as List; + return decoded.map((item) => Conversation.fromJson(item)).toList(); + } catch (e) { + debugPrint('ERROR: Failed to retrieve local conversations: $e'); + return []; + } + } + + Future saveLocalConversations(List conversations) async { + try { + // Only save essential data to reduce storage size + final lightweightConversations = conversations + .map( + (conv) => { + 'id': conv.id, + 'title': conv.title, + 'updatedAt': conv.updatedAt.toIso8601String(), + 'messageCount': conv.messages.length, + // Don't save full message content locally + }, + ) + .toList(); + + final jsonString = jsonEncode(lightweightConversations); + await _prefs.setString(_localConversationsKey, jsonString); + + debugPrint( + 'DEBUG: Saved ${conversations.length} local conversations (lightweight)', + ); + } catch (e) { + debugPrint('ERROR: Failed to save local conversations: $e'); + } + } + + /// Batch Operations for Performance + Future clearAuthData() async { + try { + // Clear auth-related data in batch + await Future.wait([ + deleteAuthToken(), + deleteSavedCredentials(), + _prefs.remove(_rememberCredentialsKey), + _prefs.remove(_activeServerIdKey), + ]); + + // Clear related cache entries + _cache.removeWhere( + (key, value) => + key.contains('auth') || + key.contains('credentials') || + key.contains('server'), + ); + _cacheTimestamps.removeWhere( + (key, value) => + key.contains('auth') || + key.contains('credentials') || + key.contains('server'), + ); + + debugPrint('DEBUG: Auth data cleared in batch operation'); + } catch (e) { + debugPrint('ERROR: Failed to clear auth data: $e'); + } + } + + Future clearAll() async { + try { + await Future.wait([_secureCredentialStorage.clearAll(), _prefs.clear()]); + + _cache.clear(); + _cacheTimestamps.clear(); + + debugPrint('DEBUG: All storage cleared'); + } catch (e) { + debugPrint('ERROR: Failed to clear all storage: $e'); + } + } + + /// Storage Health Check + Future isSecureStorageAvailable() async { + return await _secureCredentialStorage.isSecureStorageAvailable(); + } + + /// Cache Management Utilities + bool _isCacheValid(String key) { + final timestamp = _cacheTimestamps[key]; + if (timestamp == null) return false; + + return DateTime.now().difference(timestamp) < _cacheTimeout; + } + + void clearCache() { + _cache.clear(); + _cacheTimestamps.clear(); + debugPrint('DEBUG: Storage cache cleared'); + } + + /// Migration from old storage service (one-time operation) + Future migrateFromLegacyStorage() async { + try { + debugPrint('DEBUG: Starting migration from legacy storage'); + + // This would be called once during app upgrade + // Implementation would depend on the specific migration needs + // For now, the SecureCredentialStorage already handles legacy migration + + debugPrint('DEBUG: Legacy storage migration completed'); + } catch (e) { + debugPrint('ERROR: Legacy storage migration failed: $e'); + } + } + + /// Performance Monitoring + Map getStorageStats() { + return { + 'cacheSize': _cache.length, + 'cachedKeys': _cache.keys.toList(), + 'lastAccess': _cacheTimestamps.entries + .map((e) => '${e.key}: ${e.value}') + .toList(), + }; + } +} diff --git a/lib/core/services/platform_service.dart b/lib/core/services/platform_service.dart new file mode 100644 index 0000000..56dc0d6 --- /dev/null +++ b/lib/core/services/platform_service.dart @@ -0,0 +1,408 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/cupertino.dart'; +import 'dart:io' show Platform; +import '../../shared/theme/theme_extensions.dart'; + +/// Service for platform-specific features and polish +class PlatformService { + /// Check if running on iOS + static bool get isIOS => Platform.isIOS; + + /// Check if running on Android + static bool get isAndroid => Platform.isAndroid; + + /// Provide haptic feedback appropriate for the action + static void hapticFeedback({HapticType type = HapticType.light}) { + if (isIOS) { + _iOSHapticFeedback(type); + } else if (isAndroid) { + _androidHapticFeedback(type); + } + } + + /// Provide haptic feedback respecting user preferences + static void hapticFeedbackWithSettings({ + HapticType type = HapticType.light, + required bool hapticEnabled, + }) { + if (hapticEnabled) { + hapticFeedback(type: type); + } + } + + /// iOS-specific haptic feedback + static void _iOSHapticFeedback(HapticType type) { + switch (type) { + case HapticType.light: + HapticFeedback.lightImpact(); + break; + case HapticType.medium: + HapticFeedback.mediumImpact(); + break; + case HapticType.heavy: + HapticFeedback.heavyImpact(); + break; + case HapticType.selection: + HapticFeedback.selectionClick(); + break; + case HapticType.success: + // iOS has specific success haptics in newer versions + HapticFeedback.lightImpact(); + break; + case HapticType.warning: + HapticFeedback.mediumImpact(); + break; + case HapticType.error: + HapticFeedback.heavyImpact(); + break; + } + } + + /// Android-specific haptic feedback + static void _androidHapticFeedback(HapticType type) { + switch (type) { + case HapticType.light: + case HapticType.selection: + HapticFeedback.lightImpact(); + break; + case HapticType.medium: + case HapticType.success: + HapticFeedback.mediumImpact(); + break; + case HapticType.heavy: + case HapticType.warning: + case HapticType.error: + HapticFeedback.heavyImpact(); + break; + } + } + + /// Get platform-appropriate button style + static ButtonStyle getPlatformButtonStyle({ + Color? backgroundColor, + Color? foregroundColor, + EdgeInsetsGeometry? padding, + bool isDestructive = false, + }) { + // Return Material button style for both platforms since ButtonStyle is a Material concept + return ElevatedButton.styleFrom( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + padding: padding, + elevation: isDestructive ? 0 : 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + ); + } + + /// Get platform-appropriate card elevation + static double getPlatformCardElevation({bool isRaised = false}) { + if (isIOS) { + return 0; // iOS prefers flat design + } else { + return isRaised ? 4.0 : 1.0; // Android Material elevation + } + } + + /// Get platform-appropriate border radius + static BorderRadius getPlatformBorderRadius({double radius = 12}) { + if (isIOS) { + return BorderRadius.circular( + radius + 2, + ); // iOS prefers slightly more rounded + } else { + return BorderRadius.circular(radius); // Android standard + } + } + + /// Create platform-appropriate navigation transition + static Route createPlatformRoute({ + required Widget page, + RouteSettings? settings, + }) { + if (isIOS) { + return CupertinoPageRoute( + builder: (context) => page, + settings: settings, + ); + } else { + return MaterialPageRoute( + builder: (context) => page, + settings: settings, + ); + } + } + + /// Show platform-appropriate action sheet + static Future showPlatformActionSheet({ + required BuildContext context, + required String title, + List? actions, + PlatformActionSheetAction? cancelAction, + }) { + if (isIOS) { + return showCupertinoModalPopup( + context: context, + builder: (context) => CupertinoActionSheet( + title: Text(title), + actions: actions + ?.map( + (action) => CupertinoActionSheetAction( + onPressed: action.onPressed, + isDestructiveAction: action.isDestructive, + child: Text(action.title), + ), + ) + .toList(), + cancelButton: cancelAction != null + ? CupertinoActionSheetAction( + onPressed: cancelAction.onPressed, + child: Text(cancelAction.title), + ) + : null, + ), + ); + } else { + return showModalBottomSheet( + context: context, + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Text(title, style: Theme.of(context).textTheme.titleLarge), + ), + ...actions?.map( + (action) => ListTile( + title: Text( + action.title, + style: TextStyle( + color: action.isDestructive + ? Theme.of(context).colorScheme.error + : null, + ), + ), + onTap: action.onPressed, + ), + ) ?? + [], + if (cancelAction != null) + ListTile( + title: Text(cancelAction.title), + onTap: cancelAction.onPressed, + ), + ], + ), + ); + } + } + + /// Show platform-appropriate alert dialog + static Future showPlatformAlert({ + required BuildContext context, + required String title, + required String content, + String confirmText = 'OK', + String? cancelText, + bool isDestructive = false, + }) { + if (isIOS) { + return showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: Text(title), + content: Text(content), + actions: [ + if (cancelText != null) + CupertinoDialogAction( + child: Text(cancelText), + onPressed: () => Navigator.of(context).pop(false), + ), + CupertinoDialogAction( + isDestructiveAction: isDestructive, + child: Text(confirmText), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + } else { + return showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: context.conduitTheme.surfaceBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.dialog), + ), + title: Text( + title, + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + content: Text( + content, + style: TextStyle(color: context.conduitTheme.textSecondary), + ), + actions: [ + if (cancelText != null) + TextButton( + child: Text( + cancelText, + style: TextStyle(color: context.conduitTheme.textSecondary), + ), + onPressed: () => Navigator.of(context).pop(false), + ), + TextButton( + style: TextButton.styleFrom( + foregroundColor: isDestructive + ? context.conduitTheme.error + : context.conduitTheme.buttonPrimary, + ), + child: Text(confirmText), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + } + } + + /// Get platform-appropriate loading indicator + static Widget getPlatformLoadingIndicator({double size = 20, Color? color}) { + if (isIOS) { + return SizedBox( + width: size, + height: size, + child: CupertinoActivityIndicator(color: color), + ); + } else { + return SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: color != null + ? AlwaysStoppedAnimation(color) + : null, + ), + ); + } + } + + /// Get platform-appropriate switch widget + static Widget getPlatformSwitch({ + required bool value, + required ValueChanged? onChanged, + Color? activeColor, + }) { + if (isIOS) { + return CupertinoSwitch( + value: value, + onChanged: onChanged, + activeTrackColor: activeColor, + ); + } else { + return Switch( + value: value, + onChanged: onChanged, + activeColor: activeColor, + ); + } + } + + /// Apply platform-specific status bar styling + static void setPlatformStatusBarStyle({ + bool isDarkContent = false, + Color? backgroundColor, + }) { + if (isIOS) { + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarBrightness: isDarkContent + ? Brightness.light + : Brightness.dark, + statusBarIconBrightness: isDarkContent + ? Brightness.dark + : Brightness.light, + statusBarColor: backgroundColor, + ), + ); + } else { + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarColor: backgroundColor ?? Colors.transparent, + statusBarIconBrightness: isDarkContent + ? Brightness.dark + : Brightness.light, + systemNavigationBarColor: backgroundColor, + systemNavigationBarIconBrightness: isDarkContent + ? Brightness.dark + : Brightness.light, + ), + ); + } + } + + /// Check if device supports dynamic colors (Android 12+) + static bool supportsDynamicColors() { + // This would require platform channel implementation + // For now, return false + return false; + } + + /// Get platform-appropriate text selection controls + static TextSelectionControls getPlatformTextSelectionControls() { + if (isIOS) { + return cupertinoTextSelectionControls; + } else { + return materialTextSelectionControls; + } + } + + /// Create platform-specific app bar + static PreferredSizeWidget createPlatformAppBar({ + required String title, + List? actions, + Widget? leading, + bool centerTitle = false, + Color? backgroundColor, + Color? foregroundColor, + }) { + if (isIOS) { + return CupertinoNavigationBar( + middle: Text(title), + trailing: actions != null && actions.isNotEmpty + ? Row(mainAxisSize: MainAxisSize.min, children: actions) + : null, + leading: leading, + backgroundColor: backgroundColor, + ); + } else { + return AppBar( + title: Text(title), + actions: actions, + leading: leading, + centerTitle: centerTitle, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + ); + } + } +} + +/// Types of haptic feedback +enum HapticType { light, medium, heavy, selection, success, warning, error } + +/// Action sheet action configuration +class PlatformActionSheetAction { + final String title; + final VoidCallback onPressed; + final bool isDestructive; + + const PlatformActionSheetAction({ + required this.title, + required this.onPressed, + this.isDestructive = false, + }); +} diff --git a/lib/core/services/secure_credential_storage.dart b/lib/core/services/secure_credential_storage.dart new file mode 100644 index 0000000..e8c0572 --- /dev/null +++ b/lib/core/services/secure_credential_storage.dart @@ -0,0 +1,326 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:crypto/crypto.dart'; + +/// Enhanced secure credential storage with platform-specific optimizations +class SecureCredentialStorage { + late final FlutterSecureStorage _secureStorage; + + SecureCredentialStorage() { + _secureStorage = FlutterSecureStorage( + aOptions: _getAndroidOptions(), + iOptions: _getIOSOptions(), + ); + } + + static const String _credentialsKey = 'user_credentials_v2'; + static const String _serverConfigsKey = 'server_configs_v2'; + static const String _authTokenKey = 'auth_token_v2'; + + /// Get Android-specific secure storage options + AndroidOptions _getAndroidOptions() { + return const AndroidOptions( + encryptedSharedPreferences: true, + sharedPreferencesName: 'conduit_secure_prefs', + preferencesKeyPrefix: 'conduit_', + resetOnError: true, + // Use more compatible encryption algorithms + keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_PKCS1Padding, + storageCipherAlgorithm: StorageCipherAlgorithm.AES_CBC_PKCS7Padding, + ); + } + + /// Get iOS-specific secure storage options + IOSOptions _getIOSOptions() { + return const IOSOptions( + groupId: 'group.conduit.app', + accountName: 'conduit_secure_storage', + synchronizable: false, + ); + } + + /// Save user credentials securely + Future saveCredentials({ + required String serverId, + required String username, + required String password, + }) async { + try { + // First check if secure storage is available + final isAvailable = await isSecureStorageAvailable(); + if (!isAvailable) { + throw Exception('Secure storage is not available on this device'); + } + + final credentials = { + 'serverId': serverId, + 'username': username, + 'password': password, + 'savedAt': DateTime.now().toIso8601String(), + 'deviceId': await _getDeviceFingerprint(), + 'version': '2.0', // Version for migration purposes + }; + + final encryptedData = await _encryptData(jsonEncode(credentials)); + await _secureStorage.write(key: _credentialsKey, value: encryptedData); + + // Verify the save was successful by attempting to read it back + final verifyData = await _secureStorage.read(key: _credentialsKey); + if (verifyData == null || verifyData.isEmpty) { + throw Exception( + 'Failed to verify credential save - storage returned null', + ); + } + + debugPrint('DEBUG: Credentials saved and verified securely'); + } catch (e) { + debugPrint('ERROR: Failed to save credentials: $e'); + rethrow; + } + } + + /// Retrieve saved credentials + Future?> getSavedCredentials() async { + try { + final encryptedData = await _secureStorage.read(key: _credentialsKey); + if (encryptedData == null || encryptedData.isEmpty) { + return null; + } + + final jsonString = await _decryptData(encryptedData); + final decoded = jsonDecode(jsonString); + + if (decoded is! Map) { + debugPrint('Warning: Invalid credentials format'); + await deleteSavedCredentials(); + return null; + } + + // Validate device fingerprint for additional security, but be more lenient + final savedDeviceId = decoded['deviceId']?.toString(); + if (savedDeviceId != null) { + final currentDeviceId = await _getDeviceFingerprint(); + + if (savedDeviceId != currentDeviceId) { + debugPrint( + 'Info: Device fingerprint changed, but allowing credential access for better UX', + ); + // Don't clear credentials immediately - allow the user to continue + // They can re-login if needed, which will update the fingerprint + } + } + + // Validate required fields + if (!decoded.containsKey('serverId') || + !decoded.containsKey('username') || + !decoded.containsKey('password')) { + debugPrint( + 'Warning: Invalid saved credentials format - missing required fields', + ); + await deleteSavedCredentials(); + return null; + } + + // Check if credentials are too old (optional expiration) + final savedAt = decoded['savedAt']?.toString(); + if (savedAt != null) { + try { + final savedTime = DateTime.parse(savedAt); + final now = DateTime.now(); + final daysSinceCreated = now.difference(savedTime).inDays; + + // Warn if credentials are very old (but don't delete them) + if (daysSinceCreated > 90) { + debugPrint( + 'Info: Saved credentials are $daysSinceCreated days old', + ); + } + } catch (e) { + debugPrint('Warning: Could not parse savedAt timestamp: $e'); + } + } + + return { + 'serverId': decoded['serverId']?.toString() ?? '', + 'username': decoded['username']?.toString() ?? '', + 'password': decoded['password']?.toString() ?? '', + 'savedAt': decoded['savedAt']?.toString() ?? '', + }; + } catch (e) { + debugPrint('ERROR: Failed to retrieve credentials: $e'); + // Don't delete credentials on retrieval errors - they might be recoverable + return null; + } + } + + /// Delete saved credentials + Future deleteSavedCredentials() async { + try { + await _secureStorage.delete(key: _credentialsKey); + debugPrint('DEBUG: Credentials deleted'); + } catch (e) { + debugPrint('ERROR: Failed to delete credentials: $e'); + } + } + + /// Save auth token securely + Future saveAuthToken(String token) async { + try { + final encryptedToken = await _encryptData(token); + await _secureStorage.write(key: _authTokenKey, value: encryptedToken); + } catch (e) { + debugPrint('ERROR: Failed to save auth token: $e'); + rethrow; + } + } + + /// Get auth token + Future getAuthToken() async { + try { + final encryptedToken = await _secureStorage.read(key: _authTokenKey); + if (encryptedToken == null) return null; + + return await _decryptData(encryptedToken); + } catch (e) { + debugPrint('ERROR: Failed to retrieve auth token: $e'); + return null; + } + } + + /// Delete auth token + Future deleteAuthToken() async { + try { + await _secureStorage.delete(key: _authTokenKey); + } catch (e) { + debugPrint('ERROR: Failed to delete auth token: $e'); + } + } + + /// Save server configurations securely + Future saveServerConfigs(String configsJson) async { + try { + final encryptedConfigs = await _encryptData(configsJson); + await _secureStorage.write( + key: _serverConfigsKey, + value: encryptedConfigs, + ); + } catch (e) { + debugPrint('ERROR: Failed to save server configs: $e'); + rethrow; + } + } + + /// Get server configurations + Future getServerConfigs() async { + try { + final encryptedConfigs = await _secureStorage.read( + key: _serverConfigsKey, + ); + if (encryptedConfigs == null) return null; + + return await _decryptData(encryptedConfigs); + } catch (e) { + debugPrint('ERROR: Failed to retrieve server configs: $e'); + return null; + } + } + + /// Check if secure storage is available + Future isSecureStorageAvailable() async { + try { + // Test write and read + const testKey = 'test_availability'; + const testValue = 'test'; + + await _secureStorage.write(key: testKey, value: testValue); + final result = await _secureStorage.read(key: testKey); + await _secureStorage.delete(key: testKey); + + return result == testValue; + } catch (e) { + debugPrint('WARNING: Secure storage not available: $e'); + return false; + } + } + + /// Clear all secure data + Future clearAll() async { + try { + await _secureStorage.deleteAll(); + debugPrint('DEBUG: All secure data cleared'); + } catch (e) { + debugPrint('ERROR: Failed to clear secure data: $e'); + } + } + + /// Encrypt data using additional layer of encryption + Future _encryptData(String data) async { + try { + // For now, return the data as-is since FlutterSecureStorage already provides encryption + // In a more advanced implementation, you could add an additional layer of AES encryption + return data; + } catch (e) { + debugPrint('ERROR: Failed to encrypt data: $e'); + rethrow; + } + } + + /// Decrypt data + Future _decryptData(String encryptedData) async { + try { + // For now, return the data as-is since FlutterSecureStorage handles decryption + // This matches the encryption method above + return encryptedData; + } catch (e) { + debugPrint('ERROR: Failed to decrypt data: $e'); + rethrow; + } + } + + /// Generate a device fingerprint for additional security + Future _getDeviceFingerprint() async { + try { + // Create a more stable device fingerprint + final platformInfo = { + 'platform': Platform.operatingSystem, + // Use only major version to avoid fingerprint changes on minor updates + 'majorVersion': Platform.operatingSystemVersion.split('.').first, + 'isPhysicalDevice': true, // In a real implementation, you'd detect this + // Add a static component to ensure consistency + 'appId': 'conduit_app_v1', + }; + + final fingerprintData = jsonEncode(platformInfo); + final bytes = utf8.encode(fingerprintData); + final digest = sha256.convert(bytes); + + return digest.toString(); + } catch (e) { + debugPrint('WARNING: Failed to generate device fingerprint: $e'); + // Return a consistent fallback fingerprint + return 'stable_fallback_device_id'; + } + } + + /// Migrate from old storage format if needed + Future migrateFromOldStorage( + Map? oldCredentials, + ) async { + if (oldCredentials == null) return; + + try { + await saveCredentials( + serverId: oldCredentials['serverId'] ?? '', + username: oldCredentials['username'] ?? '', + password: oldCredentials['password'] ?? '', + ); + debugPrint( + 'DEBUG: Successfully migrated credentials to new secure format', + ); + } catch (e) { + debugPrint('ERROR: Failed to migrate credentials: $e'); + } + } +} diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart new file mode 100644 index 0000000..7f57a75 --- /dev/null +++ b/lib/core/services/settings_service.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'animation_service.dart'; + +/// Service for managing app-wide settings including accessibility preferences +class SettingsService { + static const String _reduceMotionKey = 'reduce_motion'; + static const String _animationSpeedKey = 'animation_speed'; + static const String _hapticFeedbackKey = 'haptic_feedback'; + static const String _highContrastKey = 'high_contrast'; + static const String _largeTextKey = 'large_text'; + static const String _darkModeKey = 'dark_mode'; + + /// Get reduced motion preference + static Future getReduceMotion() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_reduceMotionKey) ?? false; + } + + /// Set reduced motion preference + static Future setReduceMotion(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_reduceMotionKey, value); + } + + /// Get animation speed multiplier (0.5 - 2.0) + static Future getAnimationSpeed() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getDouble(_animationSpeedKey) ?? 1.0; + } + + /// Set animation speed multiplier + static Future setAnimationSpeed(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_animationSpeedKey, value.clamp(0.5, 2.0)); + } + + /// Get haptic feedback preference + static Future getHapticFeedback() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_hapticFeedbackKey) ?? true; + } + + /// Set haptic feedback preference + static Future setHapticFeedback(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_hapticFeedbackKey, value); + } + + /// Get high contrast preference + static Future getHighContrast() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_highContrastKey) ?? false; + } + + /// Set high contrast preference + static Future setHighContrast(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_highContrastKey, value); + } + + /// Get large text preference + static Future getLargeText() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_largeTextKey) ?? false; + } + + /// Set large text preference + static Future setLargeText(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_largeTextKey, value); + } + + /// Get dark mode preference + static Future getDarkMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_darkModeKey) ?? true; // Default to dark + } + + /// Set dark mode preference + static Future setDarkMode(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_darkModeKey, value); + } + + /// Load all settings + static Future loadSettings() async { + return AppSettings( + reduceMotion: await getReduceMotion(), + animationSpeed: await getAnimationSpeed(), + hapticFeedback: await getHapticFeedback(), + highContrast: await getHighContrast(), + largeText: await getLargeText(), + darkMode: await getDarkMode(), + ); + } + + /// Save all settings + static Future saveSettings(AppSettings settings) async { + await Future.wait([ + setReduceMotion(settings.reduceMotion), + setAnimationSpeed(settings.animationSpeed), + setHapticFeedback(settings.hapticFeedback), + setHighContrast(settings.highContrast), + setLargeText(settings.largeText), + setDarkMode(settings.darkMode), + ]); + } + + /// Get effective animation duration considering all settings + static Duration getEffectiveAnimationDuration( + BuildContext context, + Duration defaultDuration, + AppSettings settings, + ) { + // Check system reduced motion first + if (MediaQuery.of(context).disableAnimations || settings.reduceMotion) { + return Duration.zero; + } + + // Apply user animation speed preference + final adjustedMs = + (defaultDuration.inMilliseconds / settings.animationSpeed).round(); + return Duration(milliseconds: adjustedMs.clamp(50, 1000)); + } + + /// Get text scale factor considering user preferences + static double getEffectiveTextScaleFactor( + BuildContext context, + AppSettings settings, + ) { + final textScaler = MediaQuery.of(context).textScaler; + double baseScale = textScaler.scale(1.0); + + // Apply large text preference + if (settings.largeText) { + baseScale *= 1.3; + } + + // Ensure reasonable bounds + return baseScale.clamp(0.8, 3.0); + } +} + +/// Data class for app settings +class AppSettings { + final bool reduceMotion; + final double animationSpeed; + final bool hapticFeedback; + final bool highContrast; + final bool largeText; + final bool darkMode; + + const AppSettings({ + this.reduceMotion = false, + this.animationSpeed = 1.0, + this.hapticFeedback = true, + this.highContrast = false, + this.largeText = false, + this.darkMode = true, + }); + + AppSettings copyWith({ + bool? reduceMotion, + double? animationSpeed, + bool? hapticFeedback, + bool? highContrast, + bool? largeText, + bool? darkMode, + }) { + return AppSettings( + reduceMotion: reduceMotion ?? this.reduceMotion, + animationSpeed: animationSpeed ?? this.animationSpeed, + hapticFeedback: hapticFeedback ?? this.hapticFeedback, + highContrast: highContrast ?? this.highContrast, + largeText: largeText ?? this.largeText, + darkMode: darkMode ?? this.darkMode, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AppSettings && + other.reduceMotion == reduceMotion && + other.animationSpeed == animationSpeed && + other.hapticFeedback == hapticFeedback && + other.highContrast == highContrast && + other.largeText == largeText && + other.darkMode == darkMode; + } + + @override + int get hashCode { + return Object.hash( + reduceMotion, + animationSpeed, + hapticFeedback, + highContrast, + largeText, + darkMode, + ); + } +} + +/// Provider for app settings +final appSettingsProvider = + StateNotifierProvider( + (ref) => AppSettingsNotifier(), + ); + +class AppSettingsNotifier extends StateNotifier { + AppSettingsNotifier() : super(const AppSettings()) { + _loadSettings(); + } + + Future _loadSettings() async { + final settings = await SettingsService.loadSettings(); + state = settings; + } + + Future setReduceMotion(bool value) async { + state = state.copyWith(reduceMotion: value); + await SettingsService.setReduceMotion(value); + } + + Future setAnimationSpeed(double value) async { + state = state.copyWith(animationSpeed: value); + await SettingsService.setAnimationSpeed(value); + } + + Future setHapticFeedback(bool value) async { + state = state.copyWith(hapticFeedback: value); + await SettingsService.setHapticFeedback(value); + } + + Future setHighContrast(bool value) async { + state = state.copyWith(highContrast: value); + await SettingsService.setHighContrast(value); + } + + Future setLargeText(bool value) async { + state = state.copyWith(largeText: value); + await SettingsService.setLargeText(value); + } + + Future setDarkMode(bool value) async { + state = state.copyWith(darkMode: value); + await SettingsService.setDarkMode(value); + } + + Future resetToDefaults() async { + const defaultSettings = AppSettings(); + await SettingsService.saveSettings(defaultSettings); + state = defaultSettings; + } +} + +/// Provider for checking if haptic feedback should be enabled +final hapticEnabledProvider = Provider((ref) { + final settings = ref.watch(appSettingsProvider); + return settings.hapticFeedback; +}); + +/// Provider for effective animation settings +final effectiveAnimationSettingsProvider = Provider((ref) { + final appSettings = ref.watch(appSettingsProvider); + + return AnimationSettings( + reduceMotion: appSettings.reduceMotion, + performance: AnimationPerformance.adaptive, + animationSpeed: appSettings.animationSpeed, + ); +}); diff --git a/lib/core/services/storage_service.dart b/lib/core/services/storage_service.dart new file mode 100644 index 0000000..f0da75b --- /dev/null +++ b/lib/core/services/storage_service.dart @@ -0,0 +1,372 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/server_config.dart'; +import '../models/conversation.dart'; +import 'secure_credential_storage.dart'; + +class StorageService { + final FlutterSecureStorage _secureStorage; + final SharedPreferences _prefs; + final SecureCredentialStorage _secureCredentialStorage; + + StorageService({ + required FlutterSecureStorage secureStorage, + required SharedPreferences prefs, + }) : _secureStorage = secureStorage, + _prefs = prefs, + _secureCredentialStorage = SecureCredentialStorage(); + + // Secure storage keys + static const String _authTokenKey = 'auth_token'; + static const String _serverConfigsKey = 'server_configs'; + static const String _activeServerIdKey = 'active_server_id'; + static const String _credentialsKey = 'saved_credentials'; + static const String _rememberCredentialsKey = 'remember_credentials'; + + // Shared preferences keys + static const String _themeModeKey = 'theme_mode'; + static const String _localConversationsKey = 'local_conversations'; + + // Auth token management - using enhanced secure storage + Future saveAuthToken(String token) async { + // Try enhanced secure storage first, fallback to legacy if needed + try { + await _secureCredentialStorage.saveAuthToken(token); + } catch (e) { + debugPrint('Warning: Enhanced secure storage failed, using fallback: $e'); + await _secureStorage.write(key: _authTokenKey, value: token); + } + } + + Future getAuthToken() async { + // Try enhanced secure storage first, fallback to legacy if needed + try { + final token = await _secureCredentialStorage.getAuthToken(); + if (token != null) return token; + } catch (e) { + debugPrint('Warning: Enhanced secure storage failed, using fallback: $e'); + } + + // Fallback to legacy storage + return await _secureStorage.read(key: _authTokenKey); + } + + Future deleteAuthToken() async { + // Clear from both storages to ensure complete cleanup + try { + await _secureCredentialStorage.deleteAuthToken(); + } catch (e) { + debugPrint('Warning: Failed to delete from enhanced storage: $e'); + } + + await _secureStorage.delete(key: _authTokenKey); + } + + // Credential management for auto-login - using enhanced secure storage + Future saveCredentials({ + required String serverId, + required String username, + required String password, + }) async { + // Try enhanced secure storage first, fallback to legacy if needed + try { + // Check if enhanced secure storage is available + final isSecureAvailable = await _secureCredentialStorage + .isSecureStorageAvailable(); + if (!isSecureAvailable) { + debugPrint( + 'DEBUG: Enhanced secure storage not available, using legacy storage', + ); + throw Exception('Enhanced secure storage not available'); + } + + await _secureCredentialStorage.saveCredentials( + serverId: serverId, + username: username, + password: password, + ); + debugPrint('DEBUG: Credentials saved using enhanced secure storage'); + } catch (e) { + debugPrint('Warning: Enhanced secure storage failed, using fallback: $e'); + + // Fallback to legacy storage + try { + final credentials = { + 'serverId': serverId, + 'username': username, + 'password': password, + 'savedAt': DateTime.now().toIso8601String(), + }; + + await _secureStorage.write( + key: _credentialsKey, + value: jsonEncode(credentials), + ); + + // Verify the fallback save + final verifyData = await _secureStorage.read(key: _credentialsKey); + if (verifyData == null || verifyData.isEmpty) { + throw Exception( + 'Failed to save credentials even with fallback storage', + ); + } + + debugPrint('DEBUG: Credentials saved using fallback storage'); + } catch (fallbackError) { + debugPrint( + 'ERROR: Both enhanced and fallback credential storage failed: $fallbackError', + ); + rethrow; + } + } + } + + Future?> getSavedCredentials() async { + // Try enhanced secure storage first + try { + final credentials = await _secureCredentialStorage.getSavedCredentials(); + if (credentials != null) { + return credentials; + } + } catch (e) { + debugPrint('Warning: Enhanced secure storage failed, using fallback: $e'); + } + + // Fallback to legacy storage and migrate if found + try { + final jsonString = await _secureStorage.read(key: _credentialsKey); + if (jsonString == null || jsonString.isEmpty) return null; + + final decoded = jsonDecode(jsonString); + if (decoded is! Map) return null; + + // Validate that credentials have required fields + if (!decoded.containsKey('serverId') || + !decoded.containsKey('username') || + !decoded.containsKey('password')) { + debugPrint('Warning: Invalid saved credentials format'); + await deleteSavedCredentials(); + return null; + } + + final legacyCredentials = { + 'serverId': decoded['serverId']?.toString() ?? '', + 'username': decoded['username']?.toString() ?? '', + 'password': decoded['password']?.toString() ?? '', + 'savedAt': decoded['savedAt']?.toString() ?? '', + }; + + // Attempt to migrate to enhanced storage + try { + await _secureCredentialStorage.migrateFromOldStorage(legacyCredentials); + // If migration successful, clean up legacy storage + await _secureStorage.delete(key: _credentialsKey); + debugPrint( + 'DEBUG: Successfully migrated credentials to enhanced storage', + ); + } catch (e) { + debugPrint('Warning: Failed to migrate credentials: $e'); + } + + return legacyCredentials; + } catch (e) { + debugPrint('Error loading saved credentials: $e'); + return null; + } + } + + Future deleteSavedCredentials() async { + // Clear from both storages to ensure complete cleanup + try { + await _secureCredentialStorage.deleteSavedCredentials(); + } catch (e) { + debugPrint('Warning: Failed to delete from enhanced storage: $e'); + } + + await _secureStorage.delete(key: _credentialsKey); + await setRememberCredentials(false); + } + + // Remember credentials preference + Future setRememberCredentials(bool remember) async { + await _prefs.setBool(_rememberCredentialsKey, remember); + } + + bool getRememberCredentials() { + return _prefs.getBool(_rememberCredentialsKey) ?? false; + } + + // Server configuration management + Future saveServerConfigs(List configs) async { + final json = configs.map((c) => c.toJson()).toList(); + await _secureStorage.write(key: _serverConfigsKey, value: jsonEncode(json)); + } + + Future> getServerConfigs() async { + try { + final jsonString = await _secureStorage.read(key: _serverConfigsKey); + if (jsonString == null || jsonString.isEmpty) return []; + + final decoded = jsonDecode(jsonString); + if (decoded is! List) { + debugPrint('Warning: Server configs data is not a list, resetting'); + return []; + } + + final configs = []; + for (final item in decoded) { + try { + if (item is Map) { + // Validate required fields before parsing + if (item.containsKey('id') && + item.containsKey('name') && + item.containsKey('url')) { + configs.add(ServerConfig.fromJson(item)); + } else { + debugPrint( + 'Warning: Skipping invalid server config: missing required fields', + ); + } + } + } catch (e) { + debugPrint('Warning: Failed to parse server config: $e'); + // Continue with other configs + } + } + + return configs; + } catch (e) { + debugPrint('Error loading server configs: $e'); + return []; + } + } + + Future setActiveServerId(String? serverId) async { + if (serverId == null) { + await _secureStorage.delete(key: _activeServerIdKey); + } else { + await _secureStorage.write(key: _activeServerIdKey, value: serverId); + } + } + + Future getActiveServerId() async { + return await _secureStorage.read(key: _activeServerIdKey); + } + + // Theme management + String? getThemeMode() { + return _prefs.getString(_themeModeKey); + } + + Future setThemeMode(String mode) async { + await _prefs.setString(_themeModeKey, mode); + } + + // Local conversation management + Future> getLocalConversations() async { + final jsonString = _prefs.getString(_localConversationsKey); + if (jsonString == null || jsonString.isEmpty) return []; + + try { + final decoded = jsonDecode(jsonString); + if (decoded is! List) { + debugPrint( + 'Warning: Local conversations data is not a list, resetting', + ); + return []; + } + + final conversations = []; + for (final item in decoded) { + try { + if (item is Map) { + // Validate required fields before parsing + if (item.containsKey('id') && + item.containsKey('title') && + item.containsKey('createdAt') && + item.containsKey('updatedAt')) { + conversations.add(Conversation.fromJson(item)); + } else { + debugPrint( + 'Warning: Skipping invalid conversation: missing required fields', + ); + } + } + } catch (e) { + debugPrint('Warning: Failed to parse conversation: $e'); + // Continue with other conversations + } + } + + return conversations; + } catch (e) { + debugPrint('Error parsing local conversations: $e'); + return []; + } + } + + Future saveLocalConversations(List conversations) async { + try { + final json = conversations.map((c) => c.toJson()).toList(); + await _prefs.setString(_localConversationsKey, jsonEncode(json)); + } catch (e) { + debugPrint('Error saving local conversations: $e'); + } + } + + Future addLocalConversation(Conversation conversation) async { + final conversations = await getLocalConversations(); + conversations.add(conversation); + await saveLocalConversations(conversations); + } + + Future updateLocalConversation(Conversation conversation) async { + final conversations = await getLocalConversations(); + final index = conversations.indexWhere((c) => c.id == conversation.id); + if (index != -1) { + conversations[index] = conversation; + await saveLocalConversations(conversations); + } + } + + Future deleteLocalConversation(String conversationId) async { + final conversations = await getLocalConversations(); + conversations.removeWhere((c) => c.id == conversationId); + await saveLocalConversations(conversations); + } + + // Clear all data + Future clearAll() async { + // Clear enhanced secure storage + try { + await _secureCredentialStorage.clearAll(); + } catch (e) { + debugPrint('Warning: Failed to clear enhanced storage: $e'); + } + + // Clear legacy storage + await _secureStorage.deleteAll(); + await _prefs.clear(); + + debugPrint('DEBUG: All storage cleared'); + } + + // Clear only auth-related data (keeping server configs and other settings) + Future clearAuthData() async { + await deleteAuthToken(); + await deleteSavedCredentials(); + debugPrint('DEBUG: Auth data cleared'); + } + + /// Check if enhanced secure storage is available + Future isEnhancedSecureStorageAvailable() async { + try { + return await _secureCredentialStorage.isSecureStorageAvailable(); + } catch (e) { + debugPrint('Warning: Failed to check enhanced storage availability: $e'); + return false; + } + } +} diff --git a/lib/core/services/user_friendly_error_handler.dart b/lib/core/services/user_friendly_error_handler.dart new file mode 100644 index 0000000..d05f542 --- /dev/null +++ b/lib/core/services/user_friendly_error_handler.dart @@ -0,0 +1,563 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../shared/theme/theme_extensions.dart'; + +/// User-friendly error messages and recovery actions +class UserFriendlyErrorHandler { + static final UserFriendlyErrorHandler _instance = + UserFriendlyErrorHandler._internal(); + factory UserFriendlyErrorHandler() => _instance; + UserFriendlyErrorHandler._internal(); + + /// Convert technical errors to user-friendly messages + String getUserMessage(dynamic error) { + final errorString = error.toString().toLowerCase(); + + if (_isNetworkError(errorString)) { + return _getNetworkErrorMessage(errorString); + } else if (_isValidationError(errorString)) { + return _getValidationErrorMessage(errorString); + } else if (_isServerError(errorString)) { + return _getServerErrorMessage(errorString); + } else if (_isAuthenticationError(errorString)) { + return _getAuthenticationErrorMessage(errorString); + } else if (_isFileError(errorString)) { + return _getFileErrorMessage(errorString); + } else if (_isPermissionError(errorString)) { + return _getPermissionErrorMessage(errorString); + } + + // Log technical details for debugging + _logError(error); + + // Return generic user-friendly message + return 'Something unexpected happened. Please try again.'; + } + + /// Get recovery actions for the error + List getRecoveryActions(dynamic error) { + final errorString = error.toString().toLowerCase(); + + if (_isNetworkError(errorString)) { + return _getNetworkRecoveryActions(); + } else if (_isServerError(errorString)) { + return _getServerRecoveryActions(); + } else if (_isAuthenticationError(errorString)) { + return _getAuthRecoveryActions(); + } else if (_isFileError(errorString)) { + return _getFileRecoveryActions(); + } else if (_isPermissionError(errorString)) { + return _getPermissionRecoveryActions(); + } + + return _getGenericRecoveryActions(); + } + + /// Build error widget with recovery options + Widget buildErrorWidget( + dynamic error, { + VoidCallback? onRetry, + VoidCallback? onDismiss, + bool showDetails = false, + }) { + final message = getUserMessage(error); + final actions = getRecoveryActions(error); + + return ErrorCard( + message: message, + actions: actions, + onRetry: onRetry, + onDismiss: onDismiss, + showDetails: showDetails, + technicalDetails: showDetails ? error.toString() : null, + ); + } + + /// Show error dialog with recovery options + Future showErrorDialog( + BuildContext context, + dynamic error, { + VoidCallback? onRetry, + bool showDetails = false, + }) async { + final message = getUserMessage(error); + final actions = getRecoveryActions(error); + + return showDialog( + context: context, + builder: (context) => ErrorDialog( + message: message, + actions: actions, + onRetry: onRetry, + showDetails: showDetails, + technicalDetails: showDetails ? error.toString() : null, + ), + ); + } + + /// Show error snackbar with quick action + void showErrorSnackbar( + BuildContext context, + dynamic error, { + VoidCallback? onRetry, + }) { + final message = getUserMessage(error); + final actions = getRecoveryActions(error); + final primaryAction = actions.isNotEmpty ? actions.first : null; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: context.conduitTheme.error, + action: primaryAction != null && onRetry != null + ? SnackBarAction( + label: primaryAction.label, + onPressed: onRetry, + textColor: context.conduitTheme.textInverse, + ) + : null, + duration: const Duration(seconds: 4), + behavior: SnackBarBehavior.floating, + ), + ); + } + + // Network error detection and handling + bool _isNetworkError(String error) { + return error.contains('socketexception') || + error.contains('network') || + error.contains('connection') || + error.contains('timeout') || + error.contains('handshake') || + error.contains('no address associated'); + } + + String _getNetworkErrorMessage(String error) { + if (error.contains('timeout')) { + return 'Connection timed out. Please check your internet connection and try again.'; + } else if (error.contains('no address associated')) { + return 'Cannot reach the server. Please check your server URL and internet connection.'; + } else if (error.contains('connection refused')) { + return 'Server is not responding. Please verify the server is running and accessible.'; + } + return 'Network connection problem. Please check your internet connection.'; + } + + List _getNetworkRecoveryActions() { + return [ + ErrorRecoveryAction( + label: 'Retry', + action: ErrorActionType.retry, + description: 'Try the request again', + ), + ErrorRecoveryAction( + label: 'Check Connection', + action: ErrorActionType.checkConnection, + description: 'Verify your internet connection', + ), + ]; + } + + // Server error detection and handling + bool _isServerError(String error) { + return error.contains('500') || + error.contains('502') || + error.contains('503') || + error.contains('504') || + error.contains('server error') || + error.contains('internal server error'); + } + + String _getServerErrorMessage(String error) { + if (error.contains('500')) { + return 'Server is experiencing issues. This is usually temporary.'; + } else if (error.contains('502') || error.contains('503')) { + return 'Server is temporarily unavailable. Please try again in a moment.'; + } else if (error.contains('504')) { + return 'Server took too long to respond. Please try again.'; + } + return 'Server is having problems. Please try again later.'; + } + + List _getServerRecoveryActions() { + return [ + ErrorRecoveryAction( + label: 'Try Again', + action: ErrorActionType.retry, + description: 'Retry your request', + ), + ErrorRecoveryAction( + label: 'Wait & Retry', + action: ErrorActionType.retryLater, + description: 'Wait a moment then try again', + ), + ]; + } + + // Authentication error detection and handling + bool _isAuthenticationError(String error) { + return error.contains('401') || + error.contains('403') || + error.contains('unauthorized') || + error.contains('forbidden') || + error.contains('authentication') || + error.contains('token'); + } + + String _getAuthenticationErrorMessage(String error) { + if (error.contains('401') || error.contains('unauthorized')) { + return 'Your session has expired. Please sign in again.'; + } else if (error.contains('403') || error.contains('forbidden')) { + return 'You don\'t have permission to perform this action.'; + } else if (error.contains('token')) { + return 'Authentication token is invalid. Please sign in again.'; + } + return 'Authentication problem. Please sign in again.'; + } + + List _getAuthRecoveryActions() { + return [ + ErrorRecoveryAction( + label: 'Sign In', + action: ErrorActionType.signIn, + description: 'Sign in to your account', + ), + ErrorRecoveryAction( + label: 'Try Again', + action: ErrorActionType.retry, + description: 'Retry the request', + ), + ]; + } + + // Validation error detection and handling + bool _isValidationError(String error) { + return error.contains('validation') || + error.contains('invalid') || + error.contains('format') || + error.contains('required') || + error.contains('400'); + } + + String _getValidationErrorMessage(String error) { + if (error.contains('email')) { + return 'Please enter a valid email address.'; + } else if (error.contains('password')) { + return 'Password doesn\'t meet requirements. Please check and try again.'; + } else if (error.contains('required')) { + return 'Please fill in all required fields.'; + } else if (error.contains('format')) { + return 'Some information is in the wrong format. Please check and try again.'; + } + return 'Please check your input and try again.'; + } + + // File error detection and handling + bool _isFileError(String error) { + return error.contains('file') || + error.contains('path') || + error.contains('directory') || + error.contains('not found') || + error.contains('access denied'); + } + + String _getFileErrorMessage(String error) { + if (error.contains('not found')) { + return 'File not found. It may have been moved or deleted.'; + } else if (error.contains('access denied')) { + return 'Cannot access the file. Please check permissions.'; + } else if (error.contains('too large')) { + return 'File is too large. Please choose a smaller file.'; + } + return 'Problem with the file. Please try a different file.'; + } + + List _getFileRecoveryActions() { + return [ + ErrorRecoveryAction( + label: 'Choose Different File', + action: ErrorActionType.chooseFile, + description: 'Select another file', + ), + ErrorRecoveryAction( + label: 'Try Again', + action: ErrorActionType.retry, + description: 'Retry the operation', + ), + ]; + } + + // Permission error detection and handling + bool _isPermissionError(String error) { + return error.contains('permission') || + error.contains('denied') || + error.contains('unauthorized') || + error.contains('access'); + } + + String _getPermissionErrorMessage(String error) { + if (error.contains('camera')) { + return 'Camera permission is required. Please enable it in settings.'; + } else if (error.contains('storage')) { + return 'Storage permission is required. Please enable it in settings.'; + } else if (error.contains('microphone')) { + return 'Microphone permission is required. Please enable it in settings.'; + } + return 'Permission required. Please check app permissions in settings.'; + } + + List _getPermissionRecoveryActions() { + return [ + ErrorRecoveryAction( + label: 'Open Settings', + action: ErrorActionType.openSettings, + description: 'Open app settings to grant permissions', + ), + ErrorRecoveryAction( + label: 'Try Again', + action: ErrorActionType.retry, + description: 'Retry after granting permission', + ), + ]; + } + + List _getGenericRecoveryActions() { + return [ + ErrorRecoveryAction( + label: 'Try Again', + action: ErrorActionType.retry, + description: 'Retry the operation', + ), + ErrorRecoveryAction( + label: 'Go Back', + action: ErrorActionType.goBack, + description: 'Return to previous screen', + ), + ]; + } + + /// Log technical error details for debugging + void _logError(dynamic error) { + if (kDebugMode) { + debugPrint('ERROR: $error'); + if (error is Error) { + debugPrint('STACK TRACE: ${error.stackTrace}'); + } + } + + // In production, you might want to send this to a crash reporting service + // FirebaseCrashlytics.instance.recordError(error, stackTrace); + } +} + +/// Error recovery action definition +class ErrorRecoveryAction { + final String label; + final ErrorActionType action; + final String description; + final VoidCallback? customAction; + + ErrorRecoveryAction({ + required this.label, + required this.action, + required this.description, + this.customAction, + }); +} + +/// Types of error recovery actions +enum ErrorActionType { + retry, + retryLater, + goBack, + signIn, + openSettings, + checkConnection, + chooseFile, + contactSupport, + dismiss, +} + +/// Error card widget +class ErrorCard extends StatelessWidget { + final String message; + final List actions; + final VoidCallback? onRetry; + final VoidCallback? onDismiss; + final bool showDetails; + final String? technicalDetails; + + const ErrorCard({ + super.key, + required this.message, + required this.actions, + this.onRetry, + this.onDismiss, + this.showDetails = false, + this.technicalDetails, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.all(Spacing.md), + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + size: IconSize.lg, + ), + const SizedBox(width: Spacing.sm + Spacing.xs), + Expanded( + child: Text( + message, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + if (actions.isNotEmpty) ...[ + const SizedBox(height: Spacing.md), + Wrap( + spacing: 8, + children: actions.take(2).map((action) { + return ElevatedButton( + onPressed: () => _handleAction(context, action), + child: Text(action.label), + ); + }).toList(), + ), + ], + if (showDetails && technicalDetails != null) ...[ + const SizedBox(height: Spacing.md), + ExpansionTile( + title: const Text('Technical Details'), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + child: SelectableText( + technicalDetails!, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: AppTypography.labelMedium, + ), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } + + void _handleAction(BuildContext context, ErrorRecoveryAction action) { + if (action.customAction != null) { + action.customAction!(); + return; + } + + switch (action.action) { + case ErrorActionType.retry: + onRetry?.call(); + break; + case ErrorActionType.goBack: + Navigator.of(context).pop(); + break; + case ErrorActionType.dismiss: + onDismiss?.call(); + break; + case ErrorActionType.signIn: + // Navigate to sign in page + Navigator.of(context).pushReplacementNamed('/login'); + break; + case ErrorActionType.openSettings: + // Open app settings - would need platform-specific implementation + break; + default: + onRetry?.call(); + } + } +} + +/// Error dialog widget +class ErrorDialog extends StatelessWidget { + final String message; + final List actions; + final VoidCallback? onRetry; + final bool showDetails; + final String? technicalDetails; + + const ErrorDialog({ + super.key, + required this.message, + required this.actions, + this.onRetry, + this.showDetails = false, + this.technicalDetails, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Row( + children: [ + Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error), + const SizedBox(width: Spacing.sm + Spacing.xs), + const Text('Error'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(message), + if (showDetails && technicalDetails != null) ...[ + const SizedBox(height: Spacing.md), + ExpansionTile( + title: const Text('Technical Details'), + children: [ + SelectableText( + technicalDetails!, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: AppTypography.labelMedium, + ), + ), + ], + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + if (actions.isNotEmpty) + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + if (actions.first.action == ErrorActionType.retry) { + onRetry?.call(); + } + }, + child: Text(actions.first.label), + ), + ], + ); + } +} diff --git a/lib/core/utils/reasoning_parser.dart b/lib/core/utils/reasoning_parser.dart new file mode 100644 index 0000000..ec553a9 --- /dev/null +++ b/lib/core/utils/reasoning_parser.dart @@ -0,0 +1,158 @@ +import 'package:flutter/foundation.dart'; + +/// Utility class for parsing and extracting reasoning/thinking content from messages +class ReasoningParser { + /// Parses a message and extracts reasoning content + static ReasoningContent? parseReasoningContent(String content) { + if (content.isEmpty) return null; + + if (kDebugMode) { + debugPrint( + 'DEBUG: Parsing content: ${content.substring(0, content.length > 200 ? 200 : content.length)}...', + ); + } + + // Check if content contains reasoning + if (!content.contains('
tag with type="reasoning" + final reasoningRegex = RegExp( + r']*>\s*([^<]*)\s*(.*?)\s*
', + multiLine: true, + dotAll: true, + ); + + final match = reasoningRegex.firstMatch(content); + if (match == null) { + if (kDebugMode) { + debugPrint('DEBUG: Regex did not match - checking pattern'); + } + // Try a more flexible regex to debug + final flexRegex = RegExp( + r']*type="reasoning"[^>]*>.*?', + multiLine: true, + dotAll: true, + ); + final flexMatch = flexRegex.firstMatch(content); + if (flexMatch != null) { + if (kDebugMode) { + debugPrint('DEBUG: Found flexible match: ${flexMatch.group(0)}'); + } + } else { + if (kDebugMode) { + debugPrint('DEBUG: No flexible match found either'); + } + } + return null; + } + + if (kDebugMode) { + debugPrint('DEBUG: Regex matched successfully'); + } + + final isDone = match.group(1) == 'true'; + final duration = int.tryParse(match.group(2) ?? '0') ?? 0; + final summary = match.group(3)?.trim() ?? ''; + final reasoning = match.group(4)?.trim() ?? ''; + + if (kDebugMode) { + debugPrint( + 'DEBUG: Parsed values - isDone: $isDone, duration: $duration, summary: $summary', + ); + debugPrint('DEBUG: Reasoning content length: ${reasoning.length}'); + } + + // Remove the reasoning section from the main content + final mainContent = content.replaceAll(reasoningRegex, '').trim(); + + return ReasoningContent( + reasoning: reasoning, + summary: summary, + duration: duration, + isDone: isDone, + mainContent: mainContent, + originalContent: content, + ); + } + + /// Checks if a message contains reasoning content + static bool hasReasoningContent(String content) { + return content.contains('
+ identical(this, other) || + other is ReasoningContent && + runtimeType == other.runtimeType && + reasoning == other.reasoning && + summary == other.summary && + duration == other.duration && + isDone == other.isDone && + mainContent == other.mainContent && + originalContent == other.originalContent; + + @override + int get hashCode => + reasoning.hashCode ^ + summary.hashCode ^ + duration.hashCode ^ + isDone.hashCode ^ + mainContent.hashCode ^ + originalContent.hashCode; + + String get formattedDuration => ReasoningParser.formatDuration(duration); + + /// Gets the cleaned reasoning text (removes leading '>') + String get cleanedReasoning { + // Split by lines and clean each line + return reasoning + .split('\n') + .map((line) => line.startsWith('>') ? line.substring(1).trim() : line) + .join('\n') + .trim(); + } +} diff --git a/lib/core/utils/stream_chunker.dart b/lib/core/utils/stream_chunker.dart new file mode 100644 index 0000000..4e307a4 --- /dev/null +++ b/lib/core/utils/stream_chunker.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:math'; + +/// Utility class to chunk large text streams into smaller pieces for smoother UI updates +class StreamChunker { + /// Splits large text chunks into smaller pieces for more fluid streaming + /// Similar to OpenWebUI's approach for better UX + static Stream chunkStream( + Stream inputStream, { + bool enableChunking = true, + int minChunkSize = 16, // increase to reduce UI thrash + int maxChunkLength = 12, // larger chunks improve performance + Duration delayBetweenChunks = const Duration(milliseconds: 8), + }) async* { + final random = Random(); + + await for (final chunk in inputStream) { + if (!enableChunking || chunk.length < minChunkSize) { + // Small chunks pass through as-is + yield chunk; + continue; + } + + // Split large chunks into smaller pieces + String remaining = chunk; + while (remaining.isNotEmpty) { + // Random chunk size between 4 and maxChunkLength characters + // But prefer to break at word boundaries when possible + int chunkSize = min( + max(4, random.nextInt(maxChunkLength) + 1), + remaining.length, + ); + + // Try to find a word boundary (space) within the chunk size + if (chunkSize < remaining.length) { + final nextSpace = remaining.indexOf(' ', chunkSize); + if (nextSpace != -1 && nextSpace <= chunkSize + 2) { + // Include the space in the chunk for natural word breaks + chunkSize = nextSpace + 1; + } + } + + final pieceToYield = remaining.substring(0, chunkSize); + yield pieceToYield; + remaining = remaining.substring(chunkSize); + + // Add small delay between chunks for fluid animation + // Skip delay for last piece to avoid unnecessary wait + if (remaining.isNotEmpty && delayBetweenChunks.inMicroseconds > 0) { + await Future.delayed(delayBetweenChunks); + } + } + } + } + + /// Alternative method that chunks by words instead of characters + static Stream chunkByWords( + Stream inputStream, { + bool enableChunking = true, + int wordsPerChunk = 1, + Duration delayBetweenWords = const Duration(milliseconds: 50), + }) async* { + if (!enableChunking) { + yield* inputStream; + return; + } + + String buffer = ''; + + await for (final chunk in inputStream) { + buffer += chunk; + + // Split by spaces and yield word by word + final words = buffer.split(' '); + + // Keep the last "word" in buffer as it might be incomplete + if (words.length > 1) { + buffer = words.last; + final completeWords = words.sublist(0, words.length - 1); + + for (int i = 0; i < completeWords.length; i++) { + final word = completeWords[i]; + // Add space back except for the first word if buffer was empty + final wordWithSpace = + (i < completeWords.length - 1 || buffer.isNotEmpty) + ? '$word ' + : word; + + yield wordWithSpace; + + // Add delay between words for smooth streaming effect + if (i < completeWords.length - 1 && + delayBetweenWords.inMicroseconds > 0) { + await Future.delayed(delayBetweenWords); + } + } + } + } + + // Yield any remaining buffer content + if (buffer.isNotEmpty) { + yield buffer; + } + } +} diff --git a/lib/core/validation/api_validator.dart b/lib/core/validation/api_validator.dart new file mode 100644 index 0000000..e76f9ee --- /dev/null +++ b/lib/core/validation/api_validator.dart @@ -0,0 +1,416 @@ +import 'package:flutter/foundation.dart'; +import 'schema_registry.dart'; +import 'validation_result.dart'; +import 'field_mapper.dart'; + +/// Comprehensive API request and response validator +/// Validates against OpenAPI specification schemas +class ApiValidator { + static final ApiValidator _instance = ApiValidator._internal(); + factory ApiValidator() => _instance; + ApiValidator._internal(); + + final SchemaRegistry _schemaRegistry = SchemaRegistry(); + final FieldMapper _fieldMapper = FieldMapper(); + + bool _initialized = false; + + bool get isInitialized => _initialized; + + /// Initialize validator with OpenAPI schemas + Future initialize() async { + if (_initialized) return; + + try { + await _schemaRegistry.loadSchemas(); + _initialized = true; + debugPrint('ApiValidator: Successfully initialized with schemas'); + } catch (e) { + debugPrint('ApiValidator: Failed to initialize: $e'); + // Continue without validation if schemas can't be loaded + } + } + + /// Validate request payload before sending to API + ValidationResult validateRequest( + dynamic data, + String endpoint, { + String method = 'GET', + }) { + if (!_initialized) { + return ValidationResult.warning( + 'Validator not initialized - skipping validation', + ); + } + + try { + final schema = _schemaRegistry.getRequestSchema(endpoint, method); + if (schema == null) { + return ValidationResult.warning( + 'No schema found for $method $endpoint', + ); + } + + // Transform field names for API (camelCase -> snake_case) + final transformedData = _fieldMapper.toApiFormat(data); + + // Validate against schema + return _validateAgainstSchema(transformedData, schema, 'request'); + } catch (e) { + return ValidationResult.error('Request validation failed: $e'); + } + } + + /// Validate response payload after receiving from API + ValidationResult validateResponse( + dynamic data, + String endpoint, { + String method = 'GET', + int? statusCode, + }) { + if (!_initialized) { + return ValidationResult.warning( + 'Validator not initialized - skipping validation', + ); + } + + try { + final schema = _schemaRegistry.getResponseSchema( + endpoint, + method, + statusCode, + ); + if (schema == null) { + return ValidationResult.warning( + 'No schema found for $method $endpoint response', + ); + } + + // Validate against schema first + final validationResult = _validateAgainstSchema(data, schema, 'response'); + if (!validationResult.isValid) { + return validationResult; + } + + // Transform field names from API (snake_case -> camelCase) + final transformedData = _fieldMapper.fromApiFormat(data); + + return ValidationResult.success( + 'Response validated successfully', + data: transformedData, + ); + } catch (e) { + return ValidationResult.error('Response validation failed: $e'); + } + } + + /// Validate data against a specific schema + ValidationResult _validateAgainstSchema( + dynamic data, + Map schema, + String context, + ) { + final errors = []; + final warnings = []; + + try { + _validateValue(data, schema, '', errors, warnings); + + if (errors.isNotEmpty) { + return ValidationResult.error( + 'Schema validation failed for $context', + errors: errors, + warnings: warnings, + ); + } + + if (warnings.isNotEmpty) { + return ValidationResult.warning( + 'Schema validation passed with warnings for $context', + warnings: warnings, + ); + } + + return ValidationResult.success('Schema validation passed for $context'); + } catch (e) { + return ValidationResult.error('Schema validation error for $context: $e'); + } + } + + /// Recursively validate a value against schema + void _validateValue( + dynamic value, + Map schema, + String path, + List errors, + List warnings, + ) { + final type = schema['type'] as String?; + final required = schema['required'] as List? ?? []; + + // Handle null values + if (value == null) { + if (required.isNotEmpty && path.isNotEmpty) { + errors.add('Required field missing: $path'); + } + return; + } + + // Type validation + switch (type) { + case 'object': + _validateObject(value, schema, path, errors, warnings); + break; + case 'array': + _validateArray(value, schema, path, errors, warnings); + break; + case 'string': + _validateString(value, schema, path, errors, warnings); + break; + case 'number': + case 'integer': + _validateNumber(value, schema, path, errors, warnings); + break; + case 'boolean': + _validateBoolean(value, schema, path, errors, warnings); + break; + default: + // Unknown type - add warning but don't fail + warnings.add('Unknown schema type "$type" at $path'); + } + } + + void _validateObject( + dynamic value, + Map schema, + String path, + List errors, + List warnings, + ) { + if (value is! Map) { + errors.add('Expected object at $path, got ${value.runtimeType}'); + return; + } + + final valueMap = value as Map; + final properties = schema['properties'] as Map? ?? {}; + final required = (schema['required'] as List? ?? []) + .cast(); + + // Check required fields + for (final requiredField in required) { + if (!valueMap.containsKey(requiredField)) { + errors.add( + 'Required field missing: ${path.isEmpty ? '' : '$path.'}$requiredField', + ); + } + } + + // Validate each property + for (final entry in valueMap.entries) { + final fieldName = entry.key; + final fieldValue = entry.value; + final fieldPath = path.isEmpty ? fieldName : '$path.$fieldName'; + + if (properties.containsKey(fieldName)) { + _validateValue( + fieldValue, + properties[fieldName], + fieldPath, + errors, + warnings, + ); + } else { + // Additional property - warn but don't error + warnings.add('Additional property found: $fieldPath'); + } + } + } + + void _validateArray( + dynamic value, + Map schema, + String path, + List errors, + List warnings, + ) { + if (value is! List) { + errors.add('Expected array at $path, got ${value.runtimeType}'); + return; + } + + final array = value; + final items = schema['items'] as Map?; + final minItems = schema['minItems'] as int?; + final maxItems = schema['maxItems'] as int?; + + // Validate array constraints + if (minItems != null && array.length < minItems) { + errors.add( + 'Array at $path has ${array.length} items, minimum is $minItems', + ); + } + + if (maxItems != null && array.length > maxItems) { + errors.add( + 'Array at $path has ${array.length} items, maximum is $maxItems', + ); + } + + // Validate each item + if (items != null) { + for (int i = 0; i < array.length; i++) { + _validateValue(array[i], items, '$path[$i]', errors, warnings); + } + } + } + + void _validateString( + dynamic value, + Map schema, + String path, + List errors, + List warnings, + ) { + if (value is! String) { + errors.add('Expected string at $path, got ${value.runtimeType}'); + return; + } + + final string = value; + final minLength = schema['minLength'] as int?; + final maxLength = schema['maxLength'] as int?; + final pattern = schema['pattern'] as String?; + final format = schema['format'] as String?; + + if (minLength != null && string.length < minLength) { + errors.add( + 'String at $path is ${string.length} chars, minimum is $minLength', + ); + } + + if (maxLength != null && string.length > maxLength) { + errors.add( + 'String at $path is ${string.length} chars, maximum is $maxLength', + ); + } + + if (pattern != null) { + try { + final regex = RegExp(pattern); + if (!regex.hasMatch(string)) { + errors.add('String at $path does not match pattern: $pattern'); + } + } catch (e) { + warnings.add('Invalid regex pattern at $path: $pattern'); + } + } + + // Validate common formats + if (format != null) { + _validateStringFormat(string, format, path, errors, warnings); + } + } + + void _validateStringFormat( + String value, + String format, + String path, + List errors, + List warnings, + ) { + switch (format) { + case 'email': + final emailRegex = RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$'); + if (!emailRegex.hasMatch(value)) { + errors.add('Invalid email format at $path: $value'); + } + break; + case 'uri': + case 'url': + try { + Uri.parse(value); + } catch (e) { + errors.add('Invalid URL format at $path: $value'); + } + break; + case 'date': + try { + DateTime.parse(value); + } catch (e) { + errors.add('Invalid date format at $path: $value'); + } + break; + case 'date-time': + try { + DateTime.parse(value); + } catch (e) { + errors.add('Invalid datetime format at $path: $value'); + } + break; + case 'uuid': + final uuidRegex = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ); + if (!uuidRegex.hasMatch(value)) { + errors.add('Invalid UUID format at $path: $value'); + } + break; + default: + warnings.add('Unknown string format "$format" at $path'); + } + } + + void _validateNumber( + dynamic value, + Map schema, + String path, + List errors, + List warnings, + ) { + if (value is! num) { + errors.add('Expected number at $path, got ${value.runtimeType}'); + return; + } + + final number = value; + final minimum = schema['minimum'] as num?; + final maximum = schema['maximum'] as num?; + final multipleOf = schema['multipleOf'] as num?; + + if (minimum != null && number < minimum) { + errors.add('Number at $path is $number, minimum is $minimum'); + } + + if (maximum != null && number > maximum) { + errors.add('Number at $path is $number, maximum is $maximum'); + } + + if (multipleOf != null && number % multipleOf != 0) { + errors.add('Number at $path ($number) is not a multiple of $multipleOf'); + } + } + + void _validateBoolean( + dynamic value, + Map schema, + String path, + List errors, + List warnings, + ) { + if (value is! bool) { + errors.add('Expected boolean at $path, got ${value.runtimeType}'); + } + } + + /// Transform and validate data for API consumption + Map transformForApi(Map data) { + return _fieldMapper.toApiFormat(data); + } + + /// Transform and validate data from API response + Map transformFromApi(Map data) { + return _fieldMapper.fromApiFormat(data); + } +} diff --git a/lib/core/validation/field_mapper.dart b/lib/core/validation/field_mapper.dart new file mode 100644 index 0000000..54689c8 --- /dev/null +++ b/lib/core/validation/field_mapper.dart @@ -0,0 +1,267 @@ +import 'package:flutter/foundation.dart'; + +/// Handles field name transformations between API and client formats +/// Converts between snake_case (API) and camelCase (client) +class FieldMapper { + static final FieldMapper _instance = FieldMapper._internal(); + factory FieldMapper() => _instance; + FieldMapper._internal(); + + // Cache for converted field names to improve performance + final Map _toCamelCaseCache = {}; + final Map _toSnakeCaseCache = {}; + + // Special field mappings that don't follow standard conversion rules + static const Map _specialApiToClient = { + 'created_at': 'createdAt', + 'updated_at': 'updatedAt', + 'user_id': 'userId', + 'chat_id': 'chatId', + 'message_id': 'messageId', + 'session_id': 'sessionId', + 'folder_id': 'folderId', + 'share_id': 'shareId', + 'model_id': 'modelId', + 'tool_id': 'toolId', + 'function_id': 'functionId', + 'file_id': 'fileId', + 'knowledge_base_id': 'knowledgeBaseId', + 'channel_id': 'channelId', + 'note_id': 'noteId', + 'prompt_id': 'promptId', + 'memory_id': 'memoryId', + 'is_private': 'isPrivate', + 'is_enabled': 'isEnabled', + 'is_active': 'isActive', + 'is_archived': 'isArchived', + 'is_pinned': 'isPinned', + 'api_key': 'apiKey', + 'access_token': 'accessToken', + 'refresh_token': 'refreshToken', + 'content_type': 'contentType', + 'file_size': 'fileSize', + 'file_type': 'fileType', + 'mime_type': 'mimeType', + }; + + static const Map _specialClientToApi = { + 'createdAt': 'created_at', + 'updatedAt': 'updated_at', + 'userId': 'user_id', + 'chatId': 'chat_id', + 'messageId': 'message_id', + 'sessionId': 'session_id', + 'folderId': 'folder_id', + 'shareId': 'share_id', + 'modelId': 'model_id', + 'toolId': 'tool_id', + 'functionId': 'function_id', + 'fileId': 'file_id', + 'knowledgeBaseId': 'knowledge_base_id', + 'channelId': 'channel_id', + 'noteId': 'note_id', + 'promptId': 'prompt_id', + 'memoryId': 'memory_id', + 'isPrivate': 'is_private', + 'isEnabled': 'is_enabled', + 'isActive': 'is_active', + 'isArchived': 'is_archived', + 'isPinned': 'is_pinned', + 'apiKey': 'api_key', + 'accessToken': 'access_token', + 'refreshToken': 'refresh_token', + 'contentType': 'content_type', + 'fileSize': 'file_size', + 'fileType': 'file_type', + 'mimeType': 'mime_type', + }; + + /// Transform data from client format (camelCase) to API format (snake_case) + dynamic toApiFormat(dynamic data) { + if (data == null) return null; + + if (data is Map) { + return _transformMap(data, _toSnakeCase); + } else if (data is List) { + return data.map((item) => toApiFormat(item)).toList(); + } else { + return data; + } + } + + /// Transform data from API format (snake_case) to client format (camelCase) + dynamic fromApiFormat(dynamic data) { + if (data == null) return null; + + if (data is Map) { + return _transformMap(data, _toCamelCase); + } else if (data is List) { + return data.map((item) => fromApiFormat(item)).toList(); + } else { + return data; + } + } + + /// Transform a map using the provided key transformation function + Map _transformMap( + Map map, + String Function(String) keyTransform, + ) { + final transformed = {}; + + for (final entry in map.entries) { + final transformedKey = keyTransform(entry.key); + dynamic transformedValue = entry.value; + + // Recursively transform nested objects and arrays + if (transformedValue is Map) { + transformedValue = _transformMap(transformedValue, keyTransform); + } else if (transformedValue is List) { + transformedValue = transformedValue.map((item) { + if (item is Map) { + return _transformMap(item, keyTransform); + } + return item; + }).toList(); + } + + transformed[transformedKey] = transformedValue; + } + + return transformed; + } + + /// Convert snake_case to camelCase + String _toCamelCase(String snakeCase) { + // Check cache first + if (_toCamelCaseCache.containsKey(snakeCase)) { + return _toCamelCaseCache[snakeCase]!; + } + + // Check special mappings + if (_specialApiToClient.containsKey(snakeCase)) { + final result = _specialApiToClient[snakeCase]!; + _toCamelCaseCache[snakeCase] = result; + return result; + } + + // Standard conversion + if (!snakeCase.contains('_')) { + _toCamelCaseCache[snakeCase] = snakeCase; + return snakeCase; + } + + final words = snakeCase.split('_'); + final result = + words.first + words.skip(1).map((word) => _capitalize(word)).join(''); + + _toCamelCaseCache[snakeCase] = result; + return result; + } + + /// Convert camelCase to snake_case + String _toSnakeCase(String camelCase) { + // Check cache first + if (_toSnakeCaseCache.containsKey(camelCase)) { + return _toSnakeCaseCache[camelCase]!; + } + + // Check special mappings + if (_specialClientToApi.containsKey(camelCase)) { + final result = _specialClientToApi[camelCase]!; + _toSnakeCaseCache[camelCase] = result; + return result; + } + + // Standard conversion + final result = camelCase.replaceAllMapped( + RegExp(r'[A-Z]'), + (match) => '_${match.group(0)!.toLowerCase()}', + ); + + _toSnakeCaseCache[camelCase] = result; + return result; + } + + /// Capitalize first letter of a word + String _capitalize(String word) { + if (word.isEmpty) return word; + return word[0].toUpperCase() + word.substring(1).toLowerCase(); + } + + /// Convert a single field name from snake_case to camelCase + String fieldToCamelCase(String snakeCase) { + return _toCamelCase(snakeCase); + } + + /// Convert a single field name from camelCase to snake_case + String fieldToSnakeCase(String camelCase) { + return _toSnakeCase(camelCase); + } + + /// Get all cached transformations for debugging + Map getCacheInfo() { + return { + 'toCamelCacheSize': _toCamelCaseCache.length, + 'toSnakeCacheSize': _toSnakeCaseCache.length, + 'specialMappingsCount': _specialApiToClient.length, + }; + } + + /// Clear transformation caches + void clearCache() { + _toCamelCaseCache.clear(); + _toSnakeCaseCache.clear(); + debugPrint('FieldMapper: Cleared transformation caches'); + } + + /// Add custom field mapping + void addCustomMapping(String apiField, String clientField) { + _specialApiToClient[apiField] = clientField; + _specialClientToApi[clientField] = apiField; + + // Clear relevant cache entries + _toCamelCaseCache.remove(apiField); + _toSnakeCaseCache.remove(clientField); + + debugPrint('FieldMapper: Added custom mapping: $apiField <-> $clientField'); + } + + /// Validate that field transformations are reversible + bool validateTransformations() { + final errors = []; + + // Test special mappings + for (final entry in _specialApiToClient.entries) { + final apiField = entry.key; + final clientField = entry.value; + + // Test API -> Client -> API + final backToApi = _toSnakeCase(clientField); + if (backToApi != apiField) { + errors.add( + '$apiField -> $clientField -> $backToApi (should be $apiField)', + ); + } + + // Test Client -> API -> Client + final backToClient = _toCamelCase(apiField); + if (backToClient != clientField) { + errors.add( + '$clientField -> $apiField -> $backToClient (should be $clientField)', + ); + } + } + + if (errors.isNotEmpty) { + debugPrint('FieldMapper: Transformation validation errors:'); + for (final error in errors) { + debugPrint(' $error'); + } + return false; + } + + debugPrint('FieldMapper: All transformations validated successfully'); + return true; + } +} diff --git a/lib/core/validation/schema_registry.dart b/lib/core/validation/schema_registry.dart new file mode 100644 index 0000000..12c4536 --- /dev/null +++ b/lib/core/validation/schema_registry.dart @@ -0,0 +1,368 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Registry for OpenAPI schemas +/// Loads and provides access to request/response schemas for validation +class SchemaRegistry { + static final SchemaRegistry _instance = SchemaRegistry._internal(); + factory SchemaRegistry() => _instance; + SchemaRegistry._internal(); + + Map? _openApiSpec; + final Map> _requestSchemaCache = {}; + final Map> _responseSchemaCache = {}; + + bool get isLoaded => _openApiSpec != null; + + /// Load schemas from OpenAPI specification + Future loadSchemas() async { + try { + debugPrint('SchemaRegistry: Loading OpenAPI specification...'); + + // Try to load from assets first, then from file system as fallback + String openApiContent; + try { + openApiContent = await rootBundle.loadString('assets/openapi.json'); + } catch (e) { + debugPrint( + 'SchemaRegistry: Could not load from assets, trying file system...', + ); + // Fallback - in a real app you might load from network or local file + throw Exception('OpenAPI specification not found in assets'); + } + + _openApiSpec = jsonDecode(openApiContent) as Map; + + debugPrint( + 'SchemaRegistry: Successfully loaded OpenAPI spec with ${_getPaths().length} paths', + ); + + // Pre-process and cache commonly used schemas + await _buildSchemaCache(); + } catch (e) { + debugPrint('SchemaRegistry: Failed to load schemas: $e'); + rethrow; + } + } + + /// Get request schema for endpoint and method + Map? getRequestSchema(String endpoint, String method) { + if (!isLoaded) return null; + + final cacheKey = '${method.toUpperCase()}:$endpoint:request'; + if (_requestSchemaCache.containsKey(cacheKey)) { + return _requestSchemaCache[cacheKey]; + } + + try { + final pathItem = _findPathItem(endpoint); + if (pathItem == null) return null; + + final operation = pathItem[method.toLowerCase()] as Map?; + if (operation == null) return null; + + final requestBody = operation['requestBody'] as Map?; + if (requestBody == null) return null; + + final content = requestBody['content'] as Map?; + if (content == null) return null; + + // Try to find JSON content type + final jsonContent = + content['application/json'] as Map? ?? + content.values.first as Map?; + + if (jsonContent == null) return null; + + final schema = _resolveSchema( + jsonContent['schema'] as Map?, + ); + + if (schema != null) { + _requestSchemaCache[cacheKey] = schema; + } + + return schema; + } catch (e) { + debugPrint( + 'SchemaRegistry: Error getting request schema for $method $endpoint: $e', + ); + return null; + } + } + + /// Get response schema for endpoint, method, and status code + Map? getResponseSchema( + String endpoint, + String method, + int? statusCode, + ) { + if (!isLoaded) return null; + + final code = statusCode?.toString() ?? '200'; + final cacheKey = '${method.toUpperCase()}:$endpoint:response:$code'; + + if (_responseSchemaCache.containsKey(cacheKey)) { + return _responseSchemaCache[cacheKey]; + } + + try { + final pathItem = _findPathItem(endpoint); + if (pathItem == null) return null; + + final operation = pathItem[method.toLowerCase()] as Map?; + if (operation == null) return null; + + final responses = operation['responses'] as Map?; + if (responses == null) return null; + + // Try to find the specific status code, or fall back to 'default' or '200' + final response = + responses[code] as Map? ?? + responses['default'] as Map? ?? + responses['200'] as Map?; + + if (response == null) return null; + + final content = response['content'] as Map?; + if (content == null) return null; + + // Try to find JSON content type + final jsonContent = + content['application/json'] as Map? ?? + content.values.first as Map?; + + if (jsonContent == null) return null; + + final schema = _resolveSchema( + jsonContent['schema'] as Map?, + ); + + if (schema != null) { + _responseSchemaCache[cacheKey] = schema; + } + + return schema; + } catch (e) { + debugPrint( + 'SchemaRegistry: Error getting response schema for $method $endpoint ($code): $e', + ); + return null; + } + } + + /// Find path item that matches the given endpoint + Map? _findPathItem(String endpoint) { + final paths = _getPaths(); + + // Try exact match first + if (paths.containsKey(endpoint)) { + return paths[endpoint] as Map?; + } + + // Try to find parameterized routes + for (final pathPattern in paths.keys) { + if (_matchesPathPattern(endpoint, pathPattern)) { + return paths[pathPattern] as Map?; + } + } + + return null; + } + + /// Check if endpoint matches a path pattern with parameters + bool _matchesPathPattern(String endpoint, String pattern) { + // Convert OpenAPI path parameters {id} to regex + final regexPattern = pattern.replaceAllMapped( + RegExp(r'\{([^}]+)\}'), + (match) => r'([^/]+)', + ); + + final regex = RegExp('^$regexPattern\$'); + return regex.hasMatch(endpoint); + } + + /// Get paths from OpenAPI spec + Map _getPaths() { + return _openApiSpec?['paths'] as Map? ?? {}; + } + + /// Resolve schema references ($ref) + Map? _resolveSchema(Map? schema) { + if (schema == null) return null; + + // Handle $ref + final ref = schema['\$ref'] as String?; + if (ref != null) { + return _resolveReference(ref); + } + + // Handle allOf, oneOf, anyOf + if (schema.containsKey('allOf')) { + return _mergeAllOfSchemas(schema['allOf'] as List); + } + + if (schema.containsKey('oneOf') || schema.containsKey('anyOf')) { + // For now, just take the first schema in oneOf/anyOf + final schemas = (schema['oneOf'] ?? schema['anyOf']) as List; + if (schemas.isNotEmpty) { + return _resolveSchema(schemas.first as Map?); + } + } + + // Recursively resolve nested schemas + final resolved = Map.from(schema); + + if (resolved.containsKey('properties')) { + final properties = resolved['properties'] as Map; + final resolvedProperties = {}; + + for (final entry in properties.entries) { + resolvedProperties[entry.key] = _resolveSchema( + entry.value as Map?, + ); + } + + resolved['properties'] = resolvedProperties; + } + + if (resolved.containsKey('items')) { + resolved['items'] = _resolveSchema( + resolved['items'] as Map?, + ); + } + + return resolved; + } + + /// Resolve $ref reference + Map? _resolveReference(String ref) { + if (!ref.startsWith('#/')) { + debugPrint('SchemaRegistry: External references not supported: $ref'); + return null; + } + + final path = ref.substring(2).split('/'); + dynamic current = _openApiSpec; + + for (final segment in path) { + if (current is Map && current.containsKey(segment)) { + current = current[segment]; + } else { + debugPrint('SchemaRegistry: Could not resolve reference: $ref'); + return null; + } + } + + return _resolveSchema(current as Map?); + } + + /// Merge allOf schemas + Map _mergeAllOfSchemas(List schemas) { + final merged = {}; + final mergedProperties = {}; + final mergedRequired = []; + + for (final schema in schemas) { + final resolvedSchema = _resolveSchema(schema as Map?); + if (resolvedSchema == null) continue; + + // Merge top-level properties + merged.addAll(resolvedSchema); + + // Merge properties + if (resolvedSchema.containsKey('properties')) { + mergedProperties.addAll( + resolvedSchema['properties'] as Map, + ); + } + + // Merge required fields + if (resolvedSchema.containsKey('required')) { + mergedRequired.addAll( + (resolvedSchema['required'] as List).cast(), + ); + } + } + + if (mergedProperties.isNotEmpty) { + merged['properties'] = mergedProperties; + } + + if (mergedRequired.isNotEmpty) { + merged['required'] = mergedRequired; + } + + return merged; + } + + /// Pre-build cache of commonly used schemas + Future _buildSchemaCache() async { + if (!isLoaded) return; + + final paths = _getPaths(); + int cachedCount = 0; + + for (final pathEntry in paths.entries) { + final path = pathEntry.key; + final pathItem = pathEntry.value as Map; + + for (final method in ['get', 'post', 'put', 'delete', 'patch']) { + if (pathItem.containsKey(method)) { + // Cache request schema + getRequestSchema(path, method); + + // Cache common response schemas + getResponseSchema(path, method, 200); + getResponseSchema(path, method, 201); + getResponseSchema(path, method, 400); + getResponseSchema(path, method, 401); + getResponseSchema(path, method, 403); + getResponseSchema(path, method, 404); + getResponseSchema(path, method, 422); + getResponseSchema(path, method, 500); + + cachedCount++; + } + } + } + + debugPrint( + 'SchemaRegistry: Pre-cached schemas for $cachedCount operations', + ); + } + + /// Get all available endpoints + List getAvailableEndpoints() { + if (!isLoaded) return []; + return _getPaths().keys.toList(); + } + + /// Get available methods for an endpoint + List getAvailableMethods(String endpoint) { + final pathItem = _findPathItem(endpoint); + if (pathItem == null) return []; + + return pathItem.keys + .where( + (key) => [ + 'get', + 'post', + 'put', + 'delete', + 'patch', + 'head', + 'options', + ].contains(key), + ) + .toList(); + } + + /// Clear all caches + void clearCache() { + _requestSchemaCache.clear(); + _responseSchemaCache.clear(); + } +} diff --git a/lib/core/validation/validation_interceptor.dart b/lib/core/validation/validation_interceptor.dart new file mode 100644 index 0000000..7654dd0 --- /dev/null +++ b/lib/core/validation/validation_interceptor.dart @@ -0,0 +1,217 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'api_validator.dart'; +import 'validation_result.dart'; + +/// Dio interceptor for automatic API validation +/// Validates requests and responses against OpenAPI schemas +class ValidationInterceptor extends Interceptor { + final ApiValidator _validator = ApiValidator(); + final bool enableRequestValidation; + final bool enableResponseValidation; + final bool throwOnValidationError; + final bool logValidationResults; + + ValidationInterceptor({ + this.enableRequestValidation = true, + this.enableResponseValidation = true, + this.throwOnValidationError = false, + this.logValidationResults = true, + }); + + /// Initialize the validator + Future initialize() async { + await _validator.initialize(); + } + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (enableRequestValidation && options.data != null) { + try { + final result = _validator.validateRequest( + options.data, + options.path, + method: options.method, + ); + + if (logValidationResults) { + _logValidationResult(result, 'REQUEST', options.path, options.method); + } + + if (!result.isValid && throwOnValidationError) { + throw ValidationException(result); + } + + // Transform data if validation succeeded + if (result.isValid && options.data is Map) { + options.data = _validator.transformForApi( + options.data as Map, + ); + } + } catch (e) { + if (e is ValidationException) { + handler.reject( + DioException( + requestOptions: options, + error: e, + type: DioExceptionType.unknown, + message: 'Request validation failed: ${e.result.message}', + ), + ); + return; + } else { + debugPrint('ValidationInterceptor: Request validation error: $e'); + } + } + } + + handler.next(options); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + if (enableResponseValidation && response.data != null) { + try { + final result = _validator.validateResponse( + response.data, + response.requestOptions.path, + method: response.requestOptions.method, + statusCode: response.statusCode, + ); + + if (logValidationResults) { + _logValidationResult( + result, + 'RESPONSE', + response.requestOptions.path, + response.requestOptions.method, + statusCode: response.statusCode, + ); + } + + if (!result.isValid && throwOnValidationError) { + throw ValidationException(result); + } + + // Transform data if validation succeeded and data is available + if (result.isValid && result.data != null) { + response.data = result.data; + } else if (result.isValid && response.data is Map) { + response.data = _validator.transformFromApi( + response.data as Map, + ); + } + + // Store validation result in response for debugging + if (kDebugMode) { + response.extra['validationResult'] = result; + } + } catch (e) { + if (e is ValidationException) { + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + error: e, + type: DioExceptionType.unknown, + message: 'Response validation failed: ${e.result.message}', + ), + ); + return; + } else { + debugPrint('ValidationInterceptor: Response validation error: $e'); + } + } + } + + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + // Try to validate error responses too + if (enableResponseValidation && err.response?.data != null) { + try { + final result = _validator.validateResponse( + err.response!.data, + err.requestOptions.path, + method: err.requestOptions.method, + statusCode: err.response!.statusCode, + ); + + if (logValidationResults) { + _logValidationResult( + result, + 'ERROR_RESPONSE', + err.requestOptions.path, + err.requestOptions.method, + statusCode: err.response!.statusCode, + ); + } + + // Transform error response data + if (result.isValid && result.data != null) { + err.response!.data = result.data; + } else if (result.isValid && + err.response!.data is Map) { + err.response!.data = _validator.transformFromApi( + err.response!.data as Map, + ); + } + + // Store validation result for debugging + if (kDebugMode) { + err.response!.extra['validationResult'] = result; + } + } catch (e) { + debugPrint( + 'ValidationInterceptor: Error response validation failed: $e', + ); + } + } + + handler.next(err); + } + + /// Log validation results in a structured format + void _logValidationResult( + ValidationResult result, + String type, + String path, + String method, { + int? statusCode, + }) { + if (!logValidationResults) return; + + final statusText = statusCode != null ? ' ($statusCode)' : ''; + final icon = result.isValid ? '✅' : '❌'; + + debugPrint( + '$icon Validation $type: ${method.toUpperCase()} $path$statusText - ${result.status.name}', + ); + + if (result.hasErrors) { + debugPrint(' Errors: ${result.errors.join(', ')}'); + } + + if (result.hasWarnings) { + debugPrint(' Warnings: ${result.warnings.join(', ')}'); + } + + if (result.message.isNotEmpty && + result.status != ValidationStatus.success) { + debugPrint(' Message: ${result.message}'); + } + } + + /// Get validation statistics + Map getStats() { + return { + 'requestValidationEnabled': enableRequestValidation, + 'responseValidationEnabled': enableResponseValidation, + 'throwOnError': throwOnValidationError, + 'loggingEnabled': logValidationResults, + 'validatorInitialized': _validator.isInitialized, + }; + } +} diff --git a/lib/core/validation/validation_result.dart b/lib/core/validation/validation_result.dart new file mode 100644 index 0000000..36aed1e --- /dev/null +++ b/lib/core/validation/validation_result.dart @@ -0,0 +1,100 @@ +/// Result of API validation operations +class ValidationResult { + const ValidationResult._({ + required this.isValid, + required this.status, + required this.message, + this.errors = const [], + this.warnings = const [], + this.data, + }); + + const ValidationResult.success( + String message, { + dynamic data, + List warnings = const [], + }) : this._( + isValid: true, + status: ValidationStatus.success, + message: message, + warnings: warnings, + data: data, + ); + + const ValidationResult.warning( + String message, { + List warnings = const [], + dynamic data, + }) : this._( + isValid: true, + status: ValidationStatus.warning, + message: message, + warnings: warnings, + data: data, + ); + + const ValidationResult.error( + String message, { + List errors = const [], + List warnings = const [], + }) : this._( + isValid: false, + status: ValidationStatus.error, + message: message, + errors: errors, + warnings: warnings, + ); + + final bool isValid; + final ValidationStatus status; + final String message; + final List errors; + final List warnings; + final dynamic data; + + bool get hasWarnings => warnings.isNotEmpty; + bool get hasErrors => errors.isNotEmpty; + + @override + String toString() { + final buffer = StringBuffer(); + buffer.write('ValidationResult('); + buffer.write('status: $status, '); + buffer.write('message: $message'); + + if (hasErrors) { + buffer.write(', errors: ${errors.length}'); + } + + if (hasWarnings) { + buffer.write(', warnings: ${warnings.length}'); + } + + buffer.write(')'); + return buffer.toString(); + } + + /// Convert to a detailed map for logging/debugging + Map toMap() { + return { + 'isValid': isValid, + 'status': status.name, + 'message': message, + 'errors': errors, + 'warnings': warnings, + 'hasData': data != null, + }; + } +} + +enum ValidationStatus { success, warning, error } + +/// Exception thrown when validation fails critically +class ValidationException implements Exception { + const ValidationException(this.result); + + final ValidationResult result; + + @override + String toString() => 'ValidationException: ${result.message}'; +} diff --git a/lib/core/widgets/error_boundary.dart b/lib/core/widgets/error_boundary.dart new file mode 100644 index 0000000..b73b0db --- /dev/null +++ b/lib/core/widgets/error_boundary.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../shared/theme/theme_extensions.dart'; +import '../error/enhanced_error_service.dart'; + +/// Error boundary widget that catches and handles errors in child widgets +class ErrorBoundary extends ConsumerStatefulWidget { + final Widget child; + final Widget Function(Object error, StackTrace? stack)? errorBuilder; + final void Function(Object error, StackTrace? stack)? onError; + final bool showErrorDialog; + final bool allowRetry; + + const ErrorBoundary({ + super.key, + required this.child, + this.errorBuilder, + this.onError, + this.showErrorDialog = false, + this.allowRetry = true, + }); + + @override + ConsumerState createState() => _ErrorBoundaryState(); +} + +class _ErrorBoundaryState extends ConsumerState { + Object? _error; + StackTrace? _stackTrace; + bool _hasError = false; + + @override + void initState() { + super.initState(); + + // Set up Flutter error handling for this widget + final previousOnError = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + // Forward to any previously registered handler to avoid interfering + if (previousOnError != null) { + previousOnError(details); + } + _handleError(details.exception, details.stack); + }; + } + + void _handleError(Object error, StackTrace? stack) { + // Log error + enhancedErrorService.logError( + error, + context: 'ErrorBoundary', + stackTrace: stack, + ); + + // Call custom error handler if provided + widget.onError?.call(error, stack); + + // Update state + if (mounted) { + setState(() { + _error = error; + _stackTrace = stack; + _hasError = true; + }); + + // Show error dialog if requested + if (widget.showErrorDialog && context.mounted) { + enhancedErrorService.showErrorDialog(context, error); + } + } + } + + void _retry() { + setState(() { + _error = null; + _stackTrace = null; + _hasError = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_hasError && _error != null) { + // Use custom error builder if provided + if (widget.errorBuilder != null) { + return widget.errorBuilder!(_error!, _stackTrace); + } + + // Default error UI + return Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: context.conduitTheme.error, + ), + const SizedBox(height: 16), + Text( + 'Something went wrong', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + enhancedErrorService.getUserMessage(_error!), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + if (widget.allowRetry) ...[ + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _retry, + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + ), + ], + ], + ), + ), + ), + ); + } + + // Wrap child in error handler + return Builder( + builder: (context) { + ErrorWidget.builder = (FlutterErrorDetails details) { + _handleError(details.exception, details.stack); + return const SizedBox.shrink(); + }; + + try { + return widget.child; + } catch (error, stack) { + _handleError(error, stack); + return const SizedBox.shrink(); + } + }, + ); + } +} + +/// Widget that handles async operations with proper error handling +class AsyncErrorBoundary extends ConsumerWidget { + final Future Function() builder; + final Widget? loadingWidget; + final Widget Function(Object error)? errorWidget; + final bool showRetry; + + const AsyncErrorBoundary({ + super.key, + required this.builder, + this.loadingWidget, + this.errorWidget, + this.showRetry = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return FutureBuilder( + future: builder(), + builder: (context, snapshot) { + // Loading state + if (snapshot.connectionState == ConnectionState.waiting) { + return loadingWidget ?? + const Center(child: CircularProgressIndicator()); + } + + // Error state + if (snapshot.hasError) { + final error = snapshot.error!; + + // Log error + enhancedErrorService.logError( + error, + context: 'AsyncErrorBoundary', + stackTrace: snapshot.stackTrace, + ); + + // Use custom error widget if provided + if (errorWidget != null) { + return errorWidget!(error); + } + + // Default error widget + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: context.conduitTheme.error, + ), + const SizedBox(height: 16), + Text( + enhancedErrorService.getUserMessage(error), + textAlign: TextAlign.center, + ), + if (showRetry) ...[ + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () { + // Force rebuild to retry + (context as Element).markNeedsBuild(); + }, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ], + ), + ), + ); + } + + // Success state + return snapshot.data ?? const SizedBox.shrink(); + }, + ); + } +} + +/// Stream error boundary for handling stream errors +class StreamErrorBoundary extends ConsumerWidget { + final Stream stream; + final Widget Function(T data) builder; + final Widget? loadingWidget; + final Widget Function(Object error)? errorWidget; + final T? initialData; + + const StreamErrorBoundary({ + super.key, + required this.stream, + required this.builder, + this.loadingWidget, + this.errorWidget, + this.initialData, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return StreamBuilder( + stream: stream, + initialData: initialData, + builder: (context, snapshot) { + // Error state + if (snapshot.hasError) { + final error = snapshot.error!; + + // Log error + enhancedErrorService.logError( + error, + context: 'StreamErrorBoundary', + stackTrace: snapshot.stackTrace, + ); + + // Use custom error widget if provided + if (errorWidget != null) { + return errorWidget!(error); + } + + // Default error widget + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: context.conduitTheme.error, + ), + const SizedBox(height: 16), + Text( + enhancedErrorService.getUserMessage(error), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + // Loading state + if (!snapshot.hasData) { + return loadingWidget ?? + const Center(child: CircularProgressIndicator()); + } + + // Success state + return builder(snapshot.data as T); + }, + ); + } +} diff --git a/lib/features/auth/providers/unified_auth_providers.dart b/lib/features/auth/providers/unified_auth_providers.dart new file mode 100644 index 0000000..afe9224 --- /dev/null +++ b/lib/features/auth/providers/unified_auth_providers.dart @@ -0,0 +1,107 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/auth/auth_state_manager.dart'; +import '../../../core/providers/app_providers.dart'; + +/// Unified auth providers using the new auth state manager +/// These replace the old auth providers for better efficiency + +/// Login action provider +final loginActionProvider = Provider.family, Map>(( + ref, + credentials, +) async { + final authManager = ref.read(authStateManagerProvider.notifier); + + final username = credentials['username']!; + final password = credentials['password']!; + final rememberCredentials = credentials['remember'] == 'true'; + + return await authManager.login( + username, + password, + rememberCredentials: rememberCredentials, + ); +}); + +/// Silent login action provider +final silentLoginActionProvider = Provider>((ref) async { + final authManager = ref.read(authStateManagerProvider.notifier); + return await authManager.silentLogin(); +}); + +/// Logout action provider +final logoutActionProvider = Provider>((ref) async { + final authManager = ref.read(authStateManagerProvider.notifier); + await authManager.logout(); +}); + +/// Check if saved credentials exist +final hasSavedCredentialsProvider2 = FutureProvider((ref) async { + final authManager = ref.read(authStateManagerProvider.notifier); + return await authManager.hasSavedCredentials(); +}); + +/// Computed providers for UI consumption +/// These automatically update when auth state changes + +final isAuthenticatedProvider2 = Provider((ref) { + return ref.watch( + authStateManagerProvider.select((state) => state.isAuthenticated), + ); +}); + +final authTokenProvider3 = Provider((ref) { + return ref.watch(authStateManagerProvider.select((state) => state.token)); +}); + +final currentUserProvider2 = Provider((ref) { + return ref.watch(authStateManagerProvider.select((state) => state.user)); +}); + +final authErrorProvider3 = Provider((ref) { + return ref.watch(authStateManagerProvider.select((state) => state.error)); +}); + +final isAuthLoadingProvider2 = Provider((ref) { + return ref.watch(authStateManagerProvider.select((state) => state.isLoading)); +}); + +final authStatusProvider = Provider((ref) { + return ref.watch(authStateManagerProvider.select((state) => state.status)); +}); + +/// Helper provider to trigger auth refresh +final refreshAuthProvider = Provider>((ref) async { + final authManager = ref.read(authStateManagerProvider.notifier); + await authManager.refresh(); +}); + +/// Provider to watch for auth state changes and update API service +final authApiIntegrationProvider = Provider((ref) { + ref.listen(authTokenProvider3, (previous, next) { + final api = ref.read(apiServiceProvider); + if (api != null && next != null && next.isNotEmpty) { + api.updateAuthToken(next); + } + }); +}); + +/// Navigation helper provider - determines where user should go +final authNavigationStateProvider = Provider((ref) { + final authState = ref.watch(authStateManagerProvider); + + switch (authState.status) { + case AuthStatus.initial: + case AuthStatus.loading: + return AuthNavigationState.loading; + case AuthStatus.authenticated: + return AuthNavigationState.authenticated; + case AuthStatus.unauthenticated: + case AuthStatus.tokenExpired: + return AuthNavigationState.needsLogin; + case AuthStatus.error: + return AuthNavigationState.error; + } +}); + +enum AuthNavigationState { loading, authenticated, needsLogin, error } diff --git a/lib/features/auth/views/connect_signin_page.dart b/lib/features/auth/views/connect_signin_page.dart new file mode 100644 index 0000000..922782d --- /dev/null +++ b/lib/features/auth/views/connect_signin_page.dart @@ -0,0 +1,659 @@ +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../core/models/server_config.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/services/api_service.dart'; +import '../../../core/services/input_validation_service.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/services/brand_service.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import '../../../core/auth/auth_state_manager.dart'; +import '../../chat/views/chat_page.dart'; + +class ConnectAndSignInPage extends ConsumerStatefulWidget { + const ConnectAndSignInPage({super.key}); + + @override + ConsumerState createState() => + _ConnectAndSignInPageState(); +} + +class _ConnectAndSignInPageState extends ConsumerState { + final _formKey = GlobalKey(); + + // Server controls + final TextEditingController _urlController = TextEditingController(); + String? _connectionError; + + // Auth controls + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + bool _obscurePassword = true; + String? _loginError; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _prefillFromState(); + _loadSavedCredentials(); + } + + Future _prefillFromState() async { + final activeServer = await ref.read(activeServerProvider.future); + if (activeServer != null) { + _urlController.text = activeServer.url; + } + } + + Future _loadSavedCredentials() async { + final storage = ref.read(optimizedStorageServiceProvider); + final savedCredentials = await storage.getSavedCredentials(); + if (savedCredentials != null) { + setState(() { + _usernameController.text = savedCredentials['username'] ?? ''; + }); + } + } + + @override + void dispose() { + _urlController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _connectToServer() async { + if (!_formKey.currentState!.validate()) return false; + + setState(() { + _connectionError = null; + }); + + try { + String url = _urlController.text.trim(); + if (url.isEmpty) throw Exception('URL cannot be empty'); + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'http://$url'; + } + if (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + throw Exception('Invalid URL format. Please check your input.'); + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + throw Exception('Only HTTP and HTTPS protocols are supported.'); + } + + final tempConfig = ServerConfig( + id: const Uuid().v4(), + name: _deriveServerNameFromUrl(url), + url: url, + isActive: true, + ); + + final api = ApiService(serverConfig: tempConfig); + final isHealthy = await api.checkHealth(); + if (!isHealthy) { + throw Exception('This does not appear to be an Open-WebUI server.'); + } + + await _saveServerConfig(tempConfig); + // Success + return true; + } catch (e) { + setState(() { + _connectionError = _formatConnectionError(e.toString()); + }); + return false; + } finally { + // no-op + } + } + + Future _saveServerConfig(ServerConfig config) async { + final storage = ref.read(optimizedStorageServiceProvider); + await storage.saveServerConfigs([config]); + await storage.setActiveServerId(config.id); + ref.invalidate(serverConfigsProvider); + ref.invalidate(activeServerProvider); + } + + String _deriveServerNameFromUrl(String url) { + try { + final uri = Uri.parse(url); + if (uri.host.isNotEmpty) return uri.host; + } catch (_) {} + return 'Server'; + } + + Future _signIn() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _loginError = null; + }); + + try { + final authManager = ref.read(authStateManagerProvider.notifier); + final success = await authManager.login( + _usernameController.text.trim(), + _passwordController.text, + rememberCredentials: true, + ); + if (!success) { + final authState = ref.read(authStateManagerProvider); + throw Exception(authState.error ?? 'Login failed'); + } + } catch (e) { + setState(() { + _loginError = _formatLoginError(e.toString()); + }); + } finally { + // no-op + } + } + + Future _connectAndSignIn() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _isSubmitting = true; + _connectionError = null; + _loginError = null; + }); + + try { + final connected = await _connectToServer(); + if (!connected) return; + // Wait for providers to reflect the new active server and API service + await ref.read(activeServerProvider.future); + final apiReady = await _waitForApiService(); + if (!apiReady) { + setState(() { + _connectionError = 'Setting up the connection... Please try again.'; + }); + return; + } + await _signIn(); + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + Future _waitForApiService({ + Duration timeout = const Duration(seconds: 2), + }) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + final api = ref.read(apiServiceProvider); + if (api != null) return true; + await Future.delayed(const Duration(milliseconds: 50)); + } + return ref.read(apiServiceProvider) != null; + } + + String _formatConnectionError(String error) { + if (error.contains('SocketException')) { + return 'We couldn\'t reach the server. Check your connection and that the server is running.'; + } else if (error.contains('timeout')) { + return 'Connection timed out. The server might be busy or blocked by a firewall.'; + } else if (error.contains('Invalid URL format')) { + return error.replaceFirst('Exception: ', ''); + } else if (error.contains('Missing protocol')) { + return 'Include http:// or https:// (e.g., http://192.168.1.10:3000).'; + } else if (error.contains('Only HTTP and HTTPS')) { + return 'Use http:// or https:// only.'; + } + return 'Couldn\'t connect. Double-check the address and try again.'; + } + + String _formatLoginError(String error) { + if (error.contains('401') || error.contains('Unauthorized')) { + return 'Invalid username or password. Please try again.'; + } else if (error.contains('redirect')) { + return 'The server is redirecting requests. Check your server\'s HTTPS configuration.'; + } else if (error.contains('SocketException')) { + return 'Unable to connect to server. Please check your connection.'; + } else if (error.contains('timeout')) { + return 'The request timed out. Please try again.'; + } + return 'We couldn\'t sign you in. Check your credentials and server settings.'; + } + + @override + Widget build(BuildContext context) { + final isIOS = Platform.isIOS; + final activeServerAsync = ref.watch(activeServerProvider); + final reviewerMode = ref.watch(reviewerModeProvider); + + return ErrorBoundary( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(Spacing.pagePadding), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GestureDetector( + onLongPress: () async { + HapticFeedback.mediumImpact(); + await ref + .read(reviewerModeProvider.notifier) + .toggle(); + if (!mounted) return; + final enabled = ref.read(reviewerModeProvider); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + enabled + ? 'Reviewer Mode enabled: Demo without server' + : 'Reviewer Mode disabled', + ), + ), + ); + }, + child: Stack( + alignment: Alignment.center, + children: [ + BrandService.createBrandIcon( + size: 100, + useGradient: true, + addShadow: true, + ), + if (reviewerMode) + Positioned( + bottom: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: context.conduitTheme.warning + .withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: context.conduitTheme.warning, + width: 1, + ), + ), + child: Text( + 'Reviewer Mode', + style: TextStyle( + color: context.conduitTheme.warning, + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ) + .animate() + .scale( + duration: AnimationDuration.pageTransition, + curve: Curves.easeOutBack, + ) + .then() + .shimmer(duration: AnimationDuration.typingIndicator), + + const SizedBox(height: Spacing.sectionGap), + + Text( + 'Connect and sign in', + textAlign: TextAlign.center, + style: context.conduitTheme.headingLarge?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.microInteraction, + ), + + const SizedBox(height: Spacing.comfortable), + + if (reviewerMode) ...[ + ConduitButton( + text: 'Enter Reviewer Demo', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ChatPage(), + ), + ); + }, + isSecondary: true, + isFullWidth: true, + ), + const SizedBox(height: Spacing.xs), + Text( + 'Demo mode: explore the app without a server. Some features are simulated.', + textAlign: TextAlign.center, + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + ), + ), + + const SizedBox(height: Spacing.sectionGap), + ], + + // Card container for form content + ConduitCard( + isElevated: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Step 1: Server + _SectionHeader( + icon: isIOS + ? CupertinoIcons.globe + : Icons.language, + title: 'Server', + subtitle: null, + ), + + const SizedBox(height: Spacing.sm), + + AccessibleFormField( + label: 'Server address', + hint: 'https://server', + controller: _urlController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => InputValidationService.validateUrl( + value, + required: true, + ), + ]), + keyboardType: TextInputType.url, + semanticLabel: + 'Enter your server URL or IP address', + onSubmitted: (_) => _connectAndSignIn(), + prefixIcon: Icon( + isIOS ? CupertinoIcons.globe : Icons.public, + color: context.conduitTheme.iconSecondary, + ), + ).animate().slideX( + begin: -0.08, + duration: AnimationDuration.messageSlide, + delay: AnimationDuration.microInteraction, + curve: Curves.easeOutCubic, + ), + + if (_connectionError != null) ...[ + const SizedBox(height: Spacing.sm), + _InlineMessage( + message: _connectionError!, + isError: true, + ).animate().slideX( + begin: 0.08, + duration: AnimationDuration.messageSlide, + curve: Curves.easeOutCubic, + ), + ], + + const SizedBox(height: Spacing.sectionGap), + + // Step 2: Sign in + _SectionHeader( + icon: isIOS + ? CupertinoIcons.lock + : Icons.lock_outline, + title: 'Sign in', + subtitle: null, + ), + + const SizedBox(height: Spacing.sm), + + activeServerAsync.maybeWhen( + data: (server) => server != null + ? Row( + children: [ + Icon( + isIOS + ? CupertinoIcons.link + : Icons.link_outlined, + size: IconSize.small, + color: context + .conduitTheme + .iconSecondary, + ), + const SizedBox(width: Spacing.xs), + Expanded( + child: Text( + server.url, + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + style: context + .conduitTheme + .bodySmall + ?.copyWith( + color: context + .conduitTheme + .textSecondary, + ), + ), + ), + ], + ) + : const SizedBox.shrink(), + orElse: () => const SizedBox.shrink(), + ), + + const SizedBox(height: Spacing.sm), + + AccessibleFormField( + label: 'Username or email', + hint: null, + controller: _usernameController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => + InputValidationService.validateEmailOrUsername( + value, + ), + ]), + keyboardType: TextInputType.emailAddress, + semanticLabel: 'Enter your username or email', + prefixIcon: Icon( + isIOS + ? CupertinoIcons.person + : Icons.person_outline, + color: context.conduitTheme.iconSecondary, + ), + ), + + const SizedBox(height: Spacing.comfortable), + + AccessibleFormField( + label: 'Password', + hint: null, + controller: _passwordController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => + InputValidationService.validateMinLength( + value, + 1, + fieldName: 'Password', + ), + ]), + obscureText: _obscurePassword, + semanticLabel: 'Enter your password', + prefixIcon: Icon( + isIOS + ? CupertinoIcons.lock + : Icons.lock_outline, + color: context.conduitTheme.iconSecondary, + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? (isIOS + ? CupertinoIcons.eye_slash + : Icons.visibility_off) + : (isIOS + ? CupertinoIcons.eye + : Icons.visibility), + color: context.conduitTheme.iconSecondary, + ), + onPressed: () => setState(() { + _obscurePassword = !_obscurePassword; + }), + ), + onSubmitted: (_) => _connectAndSignIn(), + ), + + if (_loginError != null) ...[ + const SizedBox(height: Spacing.sm), + _InlineMessage( + message: _loginError!, + isError: true, + ), + ], + + const SizedBox(height: Spacing.md), + + ConduitButton( + text: 'Continue', + onPressed: _isSubmitting + ? null + : _connectAndSignIn, + isLoading: _isSubmitting, + isFullWidth: true, + ).animate().scale( + duration: AnimationDuration.buttonPress, + curve: Curves.easeOutCubic, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + + const _SectionHeader({ + required this.icon, + required this.title, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, color: context.conduitTheme.iconPrimary), + const SizedBox(width: Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _InlineMessage extends StatelessWidget { + final String message; + final bool isError; + + const _InlineMessage({required this.message, this.isError = false}); + + @override + Widget build(BuildContext context) { + final isIOS = Platform.isIOS; + final color = isError + ? context.conduitTheme.error + : context.conduitTheme.success; + final bg = isError + ? context.conduitTheme.errorBackground + : context.conduitTheme.successBackground; + final icon = isError + ? (isIOS + ? CupertinoIcons.exclamationmark_circle_fill + : Icons.error_outline) + : (isIOS ? CupertinoIcons.check_mark_circled : Icons.check_circle); + + return Container( + padding: const EdgeInsets.all(Spacing.cardPadding), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: color.withValues(alpha: 0.3), + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.low, + ), + child: Row( + children: [ + Icon(icon, color: color, size: IconSize.medium), + const SizedBox(width: Spacing.comfortable), + Expanded( + child: Text( + message, + style: context.conduitTheme.bodyMedium?.copyWith(color: color), + ), + ), + ], + ), + ); + } +} + +// removed unused _ButtonProgress; ConduitButton provides built-in loading state diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart new file mode 100644 index 0000000..48b3a1e --- /dev/null +++ b/lib/features/chat/providers/chat_providers.dart @@ -0,0 +1,1425 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; +import '../../../core/models/chat_message.dart'; +import '../../../core/models/conversation.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/auth/auth_state_manager.dart'; +import '../../../core/utils/stream_chunker.dart'; + +// Chat messages for current conversation +final chatMessagesProvider = + StateNotifierProvider>((ref) { + return ChatMessagesNotifier(ref); + }); + +// Loading state for conversation (used to show chat skeletons during fetch) +final isLoadingConversationProvider = StateProvider((ref) => false); + +class ChatMessagesNotifier extends StateNotifier> { + final Ref _ref; + StreamSubscription? _messageStream; + ProviderSubscription? _conversationListener; + final List _subscriptions = []; + + ChatMessagesNotifier(this._ref) : super([]) { + // Load messages when conversation changes with proper cleanup + _conversationListener = _ref.listen(activeConversationProvider, ( + previous, + next, + ) { + debugPrint( + 'DEBUG: Active conversation changed - Previous: ${previous?.id}, Next: ${next?.id}', + ); + + // Only react when the conversation actually changes + if (previous?.id == next?.id) { + // If same conversation but server updated it (e.g., title/content), sync messages without flicker + if (previous?.updatedAt != next?.updatedAt) { + state = next?.messages ?? state; + } + return; + } + + // Cancel any existing message stream when switching conversations + _cancelMessageStream(); + + if (next != null) { + debugPrint( + 'DEBUG: Loading ${next.messages.length} messages for conversation ${next.id}', + ); + state = next.messages; + } else { + debugPrint('DEBUG: Clearing messages - no active conversation'); + state = []; + } + }); + + // ProviderSubscription will be cleaned up in dispose method + } + + void _addSubscription(StreamSubscription subscription) { + _subscriptions.add(subscription); + } + + void _cancelMessageStream() { + _messageStream?.cancel(); + _messageStream = null; + } + + void setMessageStream(StreamSubscription stream) { + _cancelMessageStream(); + _messageStream = stream; + + // Add to tracked subscriptions for comprehensive cleanup + _addSubscription(stream); + } + + void addMessage(ChatMessage message) { + state = [...state, message]; + } + + void removeLastMessage() { + if (state.isNotEmpty) { + state = state.sublist(0, state.length - 1); + } + } + + void clearMessages() { + state = []; + } + + void setMessages(List messages) { + state = messages; + } + + void updateLastMessage(String content) { + if (state.isEmpty) return; + + final lastMessage = state.last; + if (lastMessage.role != 'assistant') return; + + state = [ + ...state.sublist(0, state.length - 1), + lastMessage.copyWith(content: content), + ]; + } + + void appendToLastMessage(String content) { + debugPrint('DEBUG: appendToLastMessage called with: "$content"'); + + if (state.isEmpty) { + debugPrint('DEBUG: No messages to append to'); + return; + } + + final lastMessage = state.last; + if (lastMessage.role != 'assistant') { + debugPrint( + 'DEBUG: Last message is not assistant, role: ${lastMessage.role}', + ); + return; + } + + debugPrint( + 'DEBUG: Appending to message ${lastMessage.id}, current length: ${lastMessage.content.length}', + ); + + // If the current content is just the typing indicator, replace it instead of appending + final newContent = lastMessage.content == '[TYPING_INDICATOR]' + ? content + : lastMessage.content + content; + + state = [ + ...state.sublist(0, state.length - 1), + lastMessage.copyWith(content: newContent), + ]; + debugPrint('DEBUG: New content length: ${state.last.content.length}'); + } + + void replaceLastMessageContent(String content) { + debugPrint('DEBUG: replaceLastMessageContent called with: "$content"'); + if (state.isEmpty) { + debugPrint('DEBUG: No messages to replace content for'); + return; + } + + final lastMessage = state.last; + if (lastMessage.role != 'assistant') { + debugPrint( + 'DEBUG: Last message is not assistant, role: ${lastMessage.role}', + ); + return; + } + + debugPrint('DEBUG: Replacing content for message ${lastMessage.id}'); + state = [ + ...state.sublist(0, state.length - 1), + lastMessage.copyWith(content: content), + ]; + debugPrint('DEBUG: Replaced content length: ${state.last.content.length}'); + } + + void finishStreaming() { + if (state.isEmpty) return; + + final lastMessage = state.last; + if (lastMessage.role != 'assistant' || !lastMessage.isStreaming) return; + + state = [ + ...state.sublist(0, state.length - 1), + lastMessage.copyWith(isStreaming: false), + ]; + } + + @override + void dispose() { + debugPrint( + 'DEBUG: ChatMessagesNotifier disposing - cancelling ${_subscriptions.length} subscriptions', + ); + + // Cancel all tracked subscriptions + for (final subscription in _subscriptions) { + subscription.cancel(); + } + _subscriptions.clear(); + + // Cancel message stream specifically + _cancelMessageStream(); + + // Cancel conversation listener specifically + _conversationListener?.close(); + _conversationListener = null; + + debugPrint('DEBUG: ChatMessagesNotifier disposed successfully'); + super.dispose(); + } +} + +// Start a new chat (unified function for both "New Chat" button and home screen) +void startNewChat(dynamic ref) { + debugPrint('DEBUG: Starting new chat - clearing all state'); + + // Clear active conversation + ref.read(activeConversationProvider.notifier).state = null; + + // Clear messages + ref.read(chatMessagesProvider.notifier).clearMessages(); + + debugPrint('DEBUG: New chat state cleared'); +} + +// Available tools provider +final availableToolsProvider = StateProvider>((ref) => []); + +// Web search enabled state for API-based web search +final webSearchEnabledProvider = StateProvider((ref) => false); + +// Vision capable models provider +final visionCapableModelsProvider = StateProvider>((ref) { + final selectedModel = ref.watch(selectedModelProvider); + if (selectedModel == null) return []; + + // Check if the model supports vision (multimodal) + if (selectedModel.isMultimodal == true) { + return [selectedModel.id]; + } + + // For now, assume all models support vision unless explicitly marked + // This can be enhanced with proper model capability detection + return [selectedModel.id]; +}); + +// File upload capable models provider +final fileUploadCapableModelsProvider = StateProvider>((ref) { + final selectedModel = ref.watch(selectedModelProvider); + if (selectedModel == null) return []; + + // For now, assume all models support file upload + // This can be enhanced with proper model capability detection + return [selectedModel.id]; +}); + +// Helper function to validate file size +bool validateFileSize(int fileSize, int? maxSizeMB) { + if (maxSizeMB == null) return true; + final maxSizeBytes = maxSizeMB * 1024 * 1024; + return fileSize <= maxSizeBytes; +} + +// Helper function to validate file count +bool validateFileCount(int currentCount, int newFilesCount, int? maxCount) { + if (maxCount == null) return true; + return (currentCount + newFilesCount) <= maxCount; +} + +// Helper function to get file content as base64 +Future _getFileAsBase64(dynamic api, String fileId) async { + debugPrint('DEBUG: _getFileAsBase64 called for fileId: $fileId'); + + // Check if this is already a data URL (for images) + if (fileId.startsWith('data:')) { + debugPrint('DEBUG: FileId is already a data URL, returning as-is'); + return fileId; + } + + try { + // First, get file info to determine if it's an image + debugPrint('DEBUG: Getting file info for $fileId'); + final fileInfo = await api.getFileInfo(fileId); + debugPrint('DEBUG: File info received: $fileInfo'); + + // Try different fields for filename - check all possible field names + final fileName = + fileInfo['filename'] ?? + fileInfo['meta']?['name'] ?? + fileInfo['name'] ?? + fileInfo['file_name'] ?? + fileInfo['original_name'] ?? + fileInfo['original_filename'] ?? + ''; + + debugPrint('DEBUG: Processing file: $fileName (fileId: $fileId)'); + + final ext = fileName.toLowerCase().split('.').last; + debugPrint('DEBUG: File extension: $ext'); + + // Only process image files + if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) { + debugPrint('DEBUG: Skipping non-image file: $fileName (extension: $ext)'); + return null; + } + + debugPrint('DEBUG: Getting base64 content for image: $fileName'); + + // Get file content as base64 string + final fileContent = await api.getFileContent(fileId); + debugPrint( + 'DEBUG: Got file content for $fileName, type: ${fileContent.runtimeType}, length: ${fileContent.length}', + ); + + // The API service returns base64 string directly + return fileContent; + } catch (e) { + debugPrint('DEBUG: Error getting file content for $fileId: $e'); + return null; + } +} + +// Send message function for widgets +Future sendMessage( + WidgetRef ref, + String message, + List? attachments, +) async { + debugPrint( + 'DEBUG: sendMessage called with message: $message, attachments: $attachments', + ); + await _sendMessageInternal(ref, message, attachments); +} + +// Internal send message implementation +Future _sendMessageInternal( + dynamic ref, + String message, + List? attachments, +) async { + debugPrint('DEBUG: _sendMessageInternal called'); + debugPrint('DEBUG: Message: $message'); + debugPrint('DEBUG: Attachments: $attachments'); + + final reviewerMode = ref.read(reviewerModeProvider); + final api = ref.read(apiServiceProvider); + final selectedModel = ref.read(selectedModelProvider); + + debugPrint('DEBUG: API service: ${api != null ? 'available' : 'null'}'); + debugPrint('DEBUG: Selected model: ${selectedModel?.name ?? 'null'}'); + + if ((!reviewerMode && api == null) || selectedModel == null) { + debugPrint('DEBUG: Missing API service or model'); + throw Exception('No API service or model selected'); + } + + // Check if we need to create a new conversation first + var activeConversation = ref.read(activeConversationProvider); + + if (activeConversation == null) { + // Create new conversation locally first to ensure we have a conversation context + debugPrint('DEBUG: Creating new conversation before sending message'); + final title = message.length > 50 + ? '${message.substring(0, 50)}...' + : message; + + // Create local conversation first + final localConversation = Conversation( + id: const Uuid().v4(), + title: title, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + messages: [], + ); + + // Set as active conversation locally + ref.read(activeConversationProvider.notifier).state = localConversation; + activeConversation = localConversation; + + if (!reviewerMode) { + // Try to create on server, but don't fail if it doesn't work + try { + final serverConversation = await api.createConversation( + title: title, + messages: [], + model: selectedModel.id, + ); + final updatedConversation = localConversation.copyWith( + id: serverConversation.id, + ); + ref.read(activeConversationProvider.notifier).state = + updatedConversation; + activeConversation = updatedConversation; + debugPrint( + 'DEBUG: Created conversation ${serverConversation.id} on server', + ); + } catch (e) { + debugPrint( + 'DEBUG: Failed to create conversation on server, using local: $e', + ); + } + } + } + + // Add user message + debugPrint('DEBUG: Creating user message with attachments: $attachments'); + final userMessage = ChatMessage( + id: const Uuid().v4(), + role: 'user', + content: message, + timestamp: DateTime.now(), + attachmentIds: attachments, + ); + ref.read(chatMessagesProvider.notifier).addMessage(userMessage); + debugPrint('DEBUG: User message added with ID: ${userMessage.id}'); + + // We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode) + + // Reviewer mode: simulate a response locally and return + if (reviewerMode) { + // Add assistant message placeholder + final assistantMessage = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: '[TYPING_INDICATOR]', + timestamp: DateTime.now(), + model: selectedModel.name, + isStreaming: true, + ); + ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage); + + // Simulate token-by-token streaming + final demoText = + 'This is a demo response from Conduit.\n\nYou typed: "$message"'; + final words = demoText.split(' '); + for (final word in words) { + await Future.delayed(const Duration(milliseconds: 40)); + ref.read(chatMessagesProvider.notifier).appendToLastMessage('$word '); + } + ref.read(chatMessagesProvider.notifier).finishStreaming(); + + // Save locally + await _saveConversationLocally(ref); + return; + } + + // Get conversation history for context + final List messages = ref.read(chatMessagesProvider); + final List> conversationMessages = + >[]; + + for (final msg in messages) { + // Skip only empty assistant message placeholders that are currently streaming + // Include completed messages (both user and assistant) for conversation history + if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) { + debugPrint( + 'DEBUG: Processing message: role=${msg.role}, content=${msg.content.substring(0, msg.content.length > 50 ? 50 : msg.content.length)}..., attachments=${msg.attachmentIds}', + ); + + // Check if message has attachments (images and non-images) + if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) { + debugPrint( + 'DEBUG: Message has ${msg.attachmentIds!.length} attachments', + ); + + // Check if this is a Gemini model that requires special handling + final isGeminiModel = selectedModel.id.toLowerCase().contains('gemini'); + debugPrint('DEBUG: Is Gemini model: $isGeminiModel'); + debugPrint('DEBUG: Model ID: ${selectedModel.id}'); + debugPrint('DEBUG: Model name: ${selectedModel.name}'); + debugPrint( + 'DEBUG: Model ID lowercase: ${selectedModel.id.toLowerCase()}', + ); + debugPrint( + 'DEBUG: Contains gemini: ${selectedModel.id.toLowerCase().contains('gemini')}', + ); + + // Use the same content array format for all models (OpenWebUI standard) + final List> contentArray = []; + // Collect non-image files to include in the message map so API can forward top-level 'files' + final List> nonImageFiles = []; + + // Add text content first + if (msg.content.isNotEmpty) { + contentArray.add({'type': 'text', 'text': msg.content}); + debugPrint('DEBUG: Added text content to array'); + } + + // Add image attachments with proper MIME type handling; collect non-image attachments + for (final attachmentId in msg.attachmentIds!) { + debugPrint('DEBUG: Processing attachment: $attachmentId'); + try { + final base64Data = await _getFileAsBase64(api, attachmentId); + if (base64Data != null) { + debugPrint( + 'DEBUG: Got base64 data for attachment $attachmentId, length: ${base64Data.length}', + ); + + // Check if this is already a data URL + if (base64Data.startsWith('data:')) { + contentArray.add({ + 'type': 'image_url', + 'image_url': {'url': base64Data}, + }); + debugPrint('DEBUG: Added image with data URL'); + } else { + // For server files, determine MIME type from file extension + // Only call getFileInfo if attachmentId is not a data URL + if (!attachmentId.startsWith('data:')) { + final fileInfo = await api.getFileInfo(attachmentId); + final fileName = fileInfo['filename'] ?? ''; + final ext = fileName.toLowerCase().split('.').last; + + String mimeType = 'image/png'; // default + if (ext == 'jpg' || ext == 'jpeg') { + mimeType = 'image/jpeg'; + } else if (ext == 'gif') { + mimeType = 'image/gif'; + } else if (ext == 'webp') { + mimeType = 'image/webp'; + } + + debugPrint( + 'DEBUG: Using MIME type: $mimeType for file: $fileName', + ); + + contentArray.add({ + 'type': 'image_url', + 'image_url': {'url': 'data:$mimeType;base64,$base64Data'}, + }); + debugPrint('DEBUG: Added image with MIME type: $mimeType'); + } else { + debugPrint('DEBUG: Skipping getFileInfo for data URL'); + } + } + } else { + debugPrint( + 'DEBUG: No base64 data returned for attachment $attachmentId', + ); + // Treat as non-image file; include minimal descriptor so server can resolve by id + nonImageFiles.add({'id': attachmentId, 'type': 'file'}); + } + } catch (e) { + debugPrint('DEBUG: Failed to load attachment $attachmentId: $e'); + } + } + + debugPrint('DEBUG: Final content array length: ${contentArray.length}'); + final messageMap = { + 'role': msg.role, + 'content': contentArray, + }; + if (nonImageFiles.isNotEmpty) { + debugPrint( + 'DEBUG: Adding ${nonImageFiles.length} non-image file(s) to message map', + ); + messageMap['files'] = nonImageFiles; + } + conversationMessages.add(messageMap); + } else { + // Regular text-only message + debugPrint('DEBUG: Regular text-only message'); + conversationMessages.add({'role': msg.role, 'content': msg.content}); + } + } + } + + // Check if web search is enabled for API + final webSearchEnabled = ref.read(webSearchEnabledProvider); + + // No need for function calling tools since we're using retrieval directly + final tools = >[]; + + try { + // Use the model's actual supported parameters if available + final supportedParams = + selectedModel.supportedParameters ?? + [ + 'max_tokens', + 'tool_choice', + 'tools', + 'response_format', + 'structured_outputs', + ]; + + debugPrint( + 'DEBUG: Model ${selectedModel.name} supported parameters: ${selectedModel.supportedParameters}', + ); + debugPrint('DEBUG: Model ID: ${selectedModel.id}'); + debugPrint('DEBUG: Is multimodal: ${selectedModel.isMultimodal}'); + + // Create comprehensive model item matching OpenWebUI format exactly + final modelItem = { + 'id': selectedModel.id, + 'canonical_slug': selectedModel.id, + 'hugging_face_id': '', + 'name': selectedModel.name, + 'created': 1754089419, // Use example timestamp for consistency + 'description': + selectedModel.description ?? + 'This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrouter/horizon-alpha)\n\nNote: It\'s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training.', + 'context_length': 256000, + 'architecture': { + 'modality': 'text+image->text', + 'input_modalities': ['image', 'text'], + 'output_modalities': ['text'], + 'tokenizer': 'Other', + 'instruct_type': null, + }, + 'pricing': { + 'prompt': '0', + 'completion': '0', + 'request': '0', + 'image': '0', + 'audio': '0', + 'web_search': '0', + 'internal_reasoning': '0', + }, + 'top_provider': { + 'context_length': 256000, + 'max_completion_tokens': 128000, + 'is_moderated': false, + }, + 'per_request_limits': null, + 'supported_parameters': supportedParams, + 'connection_type': 'external', + 'owned_by': 'openai', + 'openai': { + 'id': selectedModel.id, + 'canonical_slug': selectedModel.id, + 'hugging_face_id': '', + 'name': selectedModel.name, + 'created': 1754089419, + 'description': + selectedModel.description ?? + 'This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrout' + 'er/horizon-alpha)\n\nNote: It\'s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training.', + 'context_length': 256000, + 'architecture': { + 'modality': 'text+image->text', + 'input_modalities': ['image', 'text'], + 'output_modalities': ['text'], + 'tokenizer': 'Other', + 'instruct_type': null, + }, + 'pricing': { + 'prompt': '0', + 'completion': '0', + 'request': '0', + 'image': '0', + 'audio': '0', + 'web_search': '0', + 'internal_reasoning': '0', + }, + 'top_provider': { + 'context_length': 256000, + 'max_completion_tokens': 128000, + 'is_moderated': false, + }, + 'per_request_limits': null, + 'supported_parameters': [ + 'max_tokens', + 'tool_choice', + 'tools', + 'response_format', + 'structured_outputs', + ], + 'connection_type': 'external', + }, + 'urlIdx': 0, + 'actions': [], + 'filters': [], + 'tags': [], + }; + + debugPrint('DEBUG: Using basic model item for ${selectedModel.name}'); + + debugPrint('DEBUG: Final conversationMessages being sent to API:'); + debugPrint('DEBUG: Messages count: ${conversationMessages.length}'); + for (int i = 0; i < conversationMessages.length; i++) { + final msg = conversationMessages[i]; + debugPrint( + 'DEBUG: Message $i: role=${msg['role']}, content type=${msg['content'].runtimeType}', + ); + if (msg['content'] is List) { + final contentArray = msg['content'] as List; + debugPrint( + 'DEBUG: Message $i content array length: ${contentArray.length}', + ); + for (int j = 0; j < contentArray.length; j++) { + final item = contentArray[j]; + debugPrint( + 'DEBUG: Content item $j: type=${item['type']}, has_image_url=${item.containsKey('image_url')}', + ); + } + } + } + + // Stream response using chat completions endpoint directly + final response = await api.sendMessageWithStreaming( + messages: conversationMessages, + model: selectedModel.id, + conversationId: activeConversation?.id, + tools: tools.isNotEmpty ? tools : null, + enableWebSearch: webSearchEnabled, + modelItem: modelItem, + ); + + final stream = response.stream; + final assistantMessageId = response.messageId; + final sessionId = response.sessionId; + + debugPrint( + 'DEBUG: Response IDs - Message: $assistantMessageId, Session: $sessionId', + ); + + // Add assistant message placeholder with the generated ID and immediate typing indicator + final assistantMessage = ChatMessage( + id: assistantMessageId, + role: 'assistant', + content: '[TYPING_INDICATOR]', // Show typing indicator immediately + timestamp: DateTime.now(), + model: selectedModel.name, + isStreaming: true, + ); + ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage); + + // For built-in web search, the status will be updated when function calls are detected + // in the streaming response. Manual status update is not needed here. + + // Set up stream subscription with proper management + // Apply chunking for smoother word-by-word streaming + final chunkedStream = StreamChunker.chunkStream( + stream, + enableChunking: true, + minChunkSize: 5, + maxChunkLength: 3, + delayBetweenChunks: const Duration(milliseconds: 15), + ); + + final streamSubscription = chunkedStream.listen( + (chunk) { + debugPrint('DEBUG: Received stream chunk: "$chunk"'); + ref.read(chatMessagesProvider.notifier).appendToLastMessage(chunk); + }, + + onDone: () async { + debugPrint('DEBUG: Stream completed in chat provider'); + // Don't mark streaming as complete yet - wait for server content replacement + // ref.read(chatMessagesProvider.notifier).finishStreaming(); + + // Send chat completed notification to OpenWebUI + final messages = ref.read(chatMessagesProvider); + if (messages.isNotEmpty && activeConversation != null) { + final lastMessage = messages.last; + if (lastMessage.role == 'assistant') { + try { + // Convert messages to the format expected by /api/chat/completed + final List> formattedMessages = []; + + for (final msg in messages) { + final messageMap = { + 'id': msg.id, + 'role': msg.role, + 'content': msg.content, + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + }; + + // Add model if available + if (msg.model != null) { + messageMap['model'] = msg.model; + } + + // Add sources and usage if available + if (msg.sources != null) { + messageMap['sources'] = msg.sources; + } + // Only include usage data if it's actually available from the response + if (msg.usage != null) { + messageMap['usage'] = msg.usage; + } + + formattedMessages.add(messageMap); + } + + // Send chat completed notification to OpenWebUI first + try { + debugPrint( + 'DEBUG: Sending chat completed notification to OpenWebUI', + ); + debugPrint( + 'DEBUG: Chat ID: ${activeConversation.id}, Message ID: $assistantMessageId, Messages: ${formattedMessages.length}', + ); + await api.sendChatCompleted( + chatId: activeConversation.id, + messageId: assistantMessageId, // Use message ID from response + messages: formattedMessages, + model: selectedModel.id, + modelItem: modelItem, // Include model metadata + sessionId: sessionId, // Include session ID + ); + debugPrint( + 'DEBUG: Chat completed notification sent successfully', + ); + + // Give server a moment to process title generation + await Future.delayed(const Duration(seconds: 2)); + } catch (e) { + debugPrint('DEBUG: Chat completed notification failed: $e'); + // Continue with title generation even if this fails + } + + // Only check for title generation on first assistant response (when conversation has <= 2 messages) + // Always check for server content updates + debugPrint('DEBUG: Checking for server content updates...'); + try { + final updatedConv = await api.getConversation( + activeConversation.id, + ); + + // Check for title updates only on first response + final shouldUpdateTitle = + messages.length <= 2 && + updatedConv.title != 'New Chat' && + updatedConv.title.isNotEmpty; + + // Always combine current local messages with updated server content + final currentMessages = ref.read(chatMessagesProvider); + final serverMessages = updatedConv.messages; + + // Create a map of server messages by ID for quick lookup + final serverMessageMap = {}; + for (final serverMsg in serverMessages) { + serverMessageMap[serverMsg.id] = serverMsg; + } + + // Update local messages with server content while preserving all messages + final updatedMessages = []; + for (final localMsg in currentMessages) { + final serverMsg = serverMessageMap[localMsg.id]; + + if (serverMsg != null && serverMsg.content.isNotEmpty) { + // Use server content if available and non-empty + // This replaces any temporary progress indicators with real content + debugPrint( + 'DEBUG: Replacing local content with server content for message ${localMsg.id}', + ); + debugPrint( + 'DEBUG: Local content: "${localMsg.content.substring(0, math.min(100, localMsg.content.length))}..."', + ); + debugPrint( + 'DEBUG: Server content: "${serverMsg.content.substring(0, math.min(100, serverMsg.content.length))}..."', + ); + + // Stream the server content through StreamChunker for word-by-word effect + debugPrint( + 'DEBUG: Streaming server content through chunker for word-by-word display', + ); + + // Clear only the last message content in-place to avoid list reset flicker + final currentList = [...currentMessages]; + final lastIndex = currentList.lastIndexWhere( + (m) => m.id == localMsg.id, + ); + if (lastIndex != -1) { + currentList[lastIndex] = currentList[lastIndex].copyWith( + content: '', + isStreaming: true, + ); + ref + .read(chatMessagesProvider.notifier) + .setMessages(currentList); + } + + // Create a stream from the server content and chunk it + final serverContentStream = Stream.fromIterable([ + serverMsg.content, + ]); + final chunkedStream = StreamChunker.chunkStream( + serverContentStream, + enableChunking: true, + minChunkSize: 5, + maxChunkLength: 3, + delayBetweenChunks: const Duration(milliseconds: 25), + ); + + // Process chunks + chunkedStream.listen( + (chunk) { + debugPrint('DEBUG: Server content chunk: "$chunk"'); + ref + .read(chatMessagesProvider.notifier) + .appendToLastMessage(chunk); + }, + onDone: () { + debugPrint('DEBUG: Server content streaming completed'); + // Mark streaming as complete + ref + .read(chatMessagesProvider.notifier) + .finishStreaming(); + }, + onError: (error) { + debugPrint( + 'DEBUG: Server content streaming error: $error', + ); + // Fall back to direct replacement + final currentMessages = ref.read(chatMessagesProvider); + if (currentMessages.isNotEmpty) { + final fallbackMessages = [...currentMessages]; + final lastIndex = fallbackMessages.length - 1; + fallbackMessages[lastIndex] = + fallbackMessages[lastIndex].copyWith( + content: serverMsg.content, + isStreaming: false, + ); + ref + .read(chatMessagesProvider.notifier) + .setMessages(fallbackMessages); + } + }, + ); + + // Don't add to updatedMessages here since we're streaming + continue; + } else { + // Handle case where streaming failed and we still have typing indicator + if (localMsg.content == '[TYPING_INDICATOR]') { + debugPrint( + 'DEBUG: Found orphaned typing indicator for message ${localMsg.id} - replacing with empty content', + ); + // Replace typing indicator with empty content so UI can show loading state + updatedMessages.add( + localMsg.copyWith(content: '', isStreaming: false), + ); + } else { + // Keep local message as-is + updatedMessages.add(localMsg); + } + } + } + + if (shouldUpdateTitle) { + debugPrint( + 'DEBUG: Server generated title: ${updatedConv.title}', + ); + + // Ensure the title is reasonable (not too long) + final cleanTitle = updatedConv.title.length > 100 + ? '${updatedConv.title.substring(0, 100)}...' + : updatedConv.title; + + // Update the conversation with title and combined messages + final updatedConversation = activeConversation.copyWith( + title: cleanTitle, + messages: updatedMessages, // Use combined messages! + updatedAt: DateTime.now(), + ); + + ref.read(activeConversationProvider.notifier).state = + updatedConversation; + + debugPrint('DEBUG: Conversation title updated successfully'); + } else { + // Update just the messages without changing title + final updatedConversation = activeConversation.copyWith( + messages: updatedMessages, // Use combined messages! + updatedAt: DateTime.now(), + ); + + ref.read(activeConversationProvider.notifier).state = + updatedConversation; + + debugPrint( + 'DEBUG: Conversation content updated with server response', + ); + } + + // Now mark streaming as complete since server content has replaced simulated content + ref.read(chatMessagesProvider.notifier).finishStreaming(); + debugPrint( + 'DEBUG: Streaming marked as complete after server content replacement', + ); + } catch (e) { + debugPrint('DEBUG: Failed to fetch server content: $e'); + // Mark streaming as complete even if server content replacement fails + ref.read(chatMessagesProvider.notifier).finishStreaming(); + debugPrint( + 'DEBUG: Streaming marked as complete after server content replacement failure', + ); + } + } catch (e) { + debugPrint('DEBUG: Chat completed error: $e'); + // Continue without failing the entire process + // Note: Conversation still syncs via _saveConversationToServer + + // IMPORTANT: Always mark streaming as complete even if server operations fail + ref.read(chatMessagesProvider.notifier).finishStreaming(); + debugPrint( + 'DEBUG: Streaming marked as complete after chat completed error', + ); + } + } + } + + // Save conversation to OpenWebUI server only after streaming is complete + debugPrint('DEBUG: About to save conversation to server...'); + // Add a small delay to ensure the last message content is fully updated + await Future.delayed(const Duration(milliseconds: 100)); + _saveConversationToServer(ref); + debugPrint('DEBUG: Conversation save initiated'); + }, + onError: (error) { + debugPrint('DEBUG: Stream error in chat provider: $error'); + // Mark streaming as complete on error + ref.read(chatMessagesProvider.notifier).finishStreaming(); + + // Special handling for Socket.IO streaming failures + // These indicate the server generated a response but we couldn't stream it + if (error.toString().contains( + 'Socket.IO streaming not fully implemented', + )) { + debugPrint( + 'DEBUG: Socket.IO streaming failed, but server may have generated response', + ); + debugPrint( + 'DEBUG: Keeping assistant message for server content replacement', + ); + // Don't remove the message - let the server content replacement handle it + // The onDone callback will fetch the actual response from the server + return; // Exit early to avoid removing the message + } + + // Handle streaming error - remove the assistant message placeholder for other errors + ref.read(chatMessagesProvider.notifier).removeLastMessage(); + + // Handle different types of errors + if (error.toString().contains('400')) { + // Bad request errors - likely malformed request format + debugPrint( + 'DEBUG: Bad request error (400) - malformed request format', + ); + final errorMessage = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: + '''⚠️ There was an issue with the message format. This might be because: + +• The image attachment couldn't be processed +• The request format is incompatible with the selected model +• The message contains unsupported content + +Please try sending the message again, or try without attachments.''', + timestamp: DateTime.now(), + isStreaming: false, + ); + ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); + } else if (error.toString().contains('401') || + error.toString().contains('403')) { + // Authentication errors - clear auth state and redirect to login + ref.invalidate(authStateManagerProvider); + } else if (error.toString().contains('500')) { + // Server errors - add user-friendly error message + debugPrint('DEBUG: Server error (500) - OpenWebUI server issue'); + final errorMessage = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: + '⚠️ I\'m sorry, but there was a server error. This usually means:\n\n' + '• The OpenWebUI server is experiencing issues\n' + '• The selected model might be unavailable\n' + '• There could be a temporary connection problem\n\n' + 'Please try again in a moment, or check with your server administrator if the problem persists.', + timestamp: DateTime.now(), + isStreaming: false, + ); + ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); + } else if (error.toString().contains('timeout')) { + // Timeout errors + debugPrint('DEBUG: Request timeout error'); + final errorMessage = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: + '⏱️ The request timed out. This might be because:\n\n' + '• The server is taking too long to respond\n' + '• Your internet connection is slow\n' + '• The model is processing a complex request\n\n' + 'Please try again with a shorter message or check your connection.', + timestamp: DateTime.now(), + isStreaming: false, + ); + ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); + } + + // Don't throw the error to prevent unhandled exceptions + // The error message has been added to the chat + debugPrint('DEBUG: Chat error handled gracefully: ${error.toString()}'); + }, + ); + + // Register the stream subscription for proper cleanup + ref + .read(chatMessagesProvider.notifier) + .setMessageStream(streamSubscription); + } catch (e) { + // Handle error - remove the assistant message placeholder + ref.read(chatMessagesProvider.notifier).removeLastMessage(); + + // Add user-friendly error message instead of rethrowing + if (e.toString().contains('400')) { + debugPrint('DEBUG: Bad request error (400) during initial request setup'); + final errorMessage = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: + '''⚠️ There was an issue with the message format. This might be because: + +• The image attachment couldn't be processed +• The request format is incompatible with the selected model +• The message contains unsupported content + +Please try sending the message again, or try without attachments.''', + timestamp: DateTime.now(), + isStreaming: false, + ); + ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); + } else if (e.toString().contains('500')) { + debugPrint('DEBUG: Server error (500) during initial request setup'); + final errorMessage = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: + '⚠️ Unable to connect to the AI model. The server returned an error (500).\n\n' + 'This is typically a server-side issue. Please try again or contact your administrator.', + timestamp: DateTime.now(), + isStreaming: false, + ); + ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); + } else if (e.toString().contains('404')) { + debugPrint('DEBUG: Model or endpoint not found (404)'); + final errorMessage = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: + '🤖 The selected AI model doesn\'t seem to be available.\n\n' + 'Please try selecting a different model or check with your administrator.', + timestamp: DateTime.now(), + isStreaming: false, + ); + ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); + } else { + // For other errors, provide a generic message and rethrow + debugPrint('DEBUG: Unexpected error during chat request: $e'); + final errorMessage = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: + '❌ An unexpected error occurred while processing your request.\n\n' + 'Please try again or check your connection.', + timestamp: DateTime.now(), + isStreaming: false, + ); + ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); + } + } +} + +// These polling functions are no longer needed since we use direct title generation +// via /api/v1/tasks/title/completions endpoint + +// Save current conversation to OpenWebUI server +Future _saveConversationToServer(dynamic ref) async { + try { + debugPrint('DEBUG: _saveConversationToServer started'); + final api = ref.read(apiServiceProvider); + final messages = ref.read(chatMessagesProvider); + final activeConversation = ref.read(activeConversationProvider); + final selectedModel = ref.read(selectedModelProvider); + + debugPrint( + 'DEBUG: Conversation save state - API: ${api != null}, Messages: ${messages.length}, Active: ${activeConversation?.id}, Model: ${selectedModel?.id}', + ); + + if (api == null || messages.isEmpty || activeConversation == null) { + debugPrint('DEBUG: Skipping conversation save - missing required data'); + return; + } + + // Check if the last message (assistant) has content + final lastMessage = messages.last; + if (lastMessage.role == 'assistant' && lastMessage.content.trim().isEmpty) { + debugPrint( + 'DEBUG: Skipping conversation save - assistant message has no content', + ); + return; + } + + // Update the existing conversation with all messages (including assistant response) + debugPrint( + 'DEBUG: Updating conversation ${activeConversation.id} with complete message history', + ); + + try { + await api.updateConversationWithMessages( + activeConversation.id, + messages, + model: selectedModel?.id, + ); + + // Update local state + final updatedConversation = activeConversation.copyWith( + messages: messages, + updatedAt: DateTime.now(), + ); + + ref.read(activeConversationProvider.notifier).state = updatedConversation; + debugPrint( + 'DEBUG: Successfully updated conversation on server: ${activeConversation.id}', + ); + } catch (e) { + debugPrint('DEBUG: Failed to update conversation on server: $e'); + // Fallback to local storage if server update fails + await _saveConversationLocally(ref); + return; + } + + // Refresh conversations list to show the updated conversation + debugPrint( + 'DEBUG: Invalidating conversations provider after successful save', + ); + ref.invalidate(conversationsProvider); + debugPrint('DEBUG: Conversations provider invalidated'); + } catch (e) { + debugPrint('Error saving conversation to server: $e'); + // Fallback to local storage + await _saveConversationLocally(ref); + } +} + +// Fallback: Save current conversation to local storage +Future _saveConversationLocally(dynamic ref) async { + try { + final storage = ref.read(optimizedStorageServiceProvider); + final messages = ref.read(chatMessagesProvider); + final activeConversation = ref.read(activeConversationProvider); + + if (messages.isEmpty) return; + + // Create or update conversation locally + final conversation = + activeConversation ?? + Conversation( + id: const Uuid().v4(), + title: _generateConversationTitle(messages), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + messages: messages, + ); + + final updatedConversation = conversation.copyWith( + messages: messages, + updatedAt: DateTime.now(), + ); + + if (activeConversation == null) { + await storage.addLocalConversation(updatedConversation); + ref.read(activeConversationProvider.notifier).state = updatedConversation; + } else { + await storage.updateLocalConversation(updatedConversation); + ref.read(activeConversationProvider.notifier).state = updatedConversation; + } + + ref.invalidate(conversationsProvider); + } catch (e) { + debugPrint('Error saving conversation locally: $e'); + } +} + +String _generateConversationTitle(List messages) { + final firstUserMessage = messages.firstWhere( + (msg) => msg.role == 'user', + orElse: () => ChatMessage( + id: '', + role: 'user', + content: 'New Chat', + timestamp: DateTime.now(), + ), + ); + + // Use first 50 characters of the first user message as title + final title = firstUserMessage.content.length > 50 + ? '${firstUserMessage.content.substring(0, 50)}...' + : firstUserMessage.content; + + return title.isEmpty ? 'New Chat' : title; +} + +// Pin/Unpin conversation +Future pinConversation( + WidgetRef ref, + String conversationId, + bool pinned, +) async { + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service available'); + + await api.pinConversation(conversationId, pinned); + + // Refresh conversations list to reflect the change + ref.invalidate(conversationsProvider); + + // Update active conversation if it's the one being pinned + final activeConversation = ref.read(activeConversationProvider); + if (activeConversation?.id == conversationId) { + ref.read(activeConversationProvider.notifier).state = activeConversation! + .copyWith(pinned: pinned); + } + } catch (e) { + debugPrint('Error ${pinned ? 'pinning' : 'unpinning'} conversation: $e'); + rethrow; + } +} + +// Archive/Unarchive conversation +Future archiveConversation( + WidgetRef ref, + String conversationId, + bool archived, +) async { + final api = ref.read(apiServiceProvider); + final activeConversation = ref.read(activeConversationProvider); + + // Update local state first + if (activeConversation?.id == conversationId && archived) { + ref.read(activeConversationProvider.notifier).state = null; + ref.read(chatMessagesProvider.notifier).clearMessages(); + } + + try { + if (api == null) throw Exception('No API service available'); + + await api.archiveConversation(conversationId, archived); + + // Refresh conversations list to reflect the change + ref.invalidate(conversationsProvider); + } catch (e) { + debugPrint( + 'Error ${archived ? 'archiving' : 'unarchiving'} conversation: $e', + ); + + // If server operation failed and we archived locally, restore the conversation + if (activeConversation?.id == conversationId && archived) { + ref.read(activeConversationProvider.notifier).state = activeConversation; + // Messages will be restored through the listener + } + + rethrow; + } +} + +// Share conversation +Future shareConversation(WidgetRef ref, String conversationId) async { + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service available'); + + final shareId = await api.shareConversation(conversationId); + + // Refresh conversations list to reflect the change + ref.invalidate(conversationsProvider); + + return shareId; + } catch (e) { + debugPrint('Error sharing conversation: $e'); + rethrow; + } +} + +// Clone conversation +Future cloneConversation(WidgetRef ref, String conversationId) async { + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service available'); + + final clonedConversation = await api.cloneConversation(conversationId); + + // Set the cloned conversation as active + ref.read(activeConversationProvider.notifier).state = clonedConversation; + // Load messages through the listener mechanism + // The ChatMessagesNotifier will automatically load messages when activeConversation changes + + // Refresh conversations list to show the new conversation + ref.invalidate(conversationsProvider); + } catch (e) { + debugPrint('Error cloning conversation: $e'); + rethrow; + } +} + +// Regenerate last message +final regenerateLastMessageProvider = Provider((ref) { + return () async { + final messages = ref.read(chatMessagesProvider); + if (messages.length < 2) return; + + // Find last user message with proper bounds checking + ChatMessage? lastUserMessage; + for (int i = messages.length - 2; i >= 0 && i < messages.length; i--) { + if (i >= 0 && messages[i].role == 'user') { + lastUserMessage = messages[i]; + break; + } + } + + if (lastUserMessage == null) return; + + // Remove last assistant message + ref.read(chatMessagesProvider.notifier).removeLastMessage(); + + // Resend the message + await _sendMessageInternal( + ref, + lastUserMessage.content, + lastUserMessage.attachmentIds, + ); + }; +}); + +// Stop generation provider +final stopGenerationProvider = Provider((ref) { + return () { + // This would need to be implemented with proper cancellation support + // For now, just mark streaming as complete + ref.read(chatMessagesProvider.notifier).finishStreaming(); + }; +}); diff --git a/lib/features/chat/services/conversation_search_service.dart b/lib/features/chat/services/conversation_search_service.dart new file mode 100644 index 0000000..3535eca --- /dev/null +++ b/lib/features/chat/services/conversation_search_service.dart @@ -0,0 +1,397 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/models/conversation.dart'; +import '../../../core/models/chat_message.dart'; + +/// Advanced conversation search service with multiple search strategies +class ConversationSearchService { + static const int maxResults = 50; + static const int contextLines = 2; // Lines before/after match for context + + /// Search through conversations with various criteria + Future searchConversations({ + required List conversations, + required String query, + ConversationSearchOptions options = const ConversationSearchOptions(), + }) async { + if (query.trim().isEmpty) { + return ConversationSearchResults.empty(); + } + + final normalizedQuery = query.toLowerCase().trim(); + final results = []; + + // Search through each conversation + for (final conversation in conversations) { + final matches = await _searchInConversation( + conversation: conversation, + query: normalizedQuery, + options: options, + ); + results.addAll(matches); + } + + // Sort results by relevance and date + results.sort((a, b) { + // First by relevance score (higher is better) + final relevanceCompare = b.relevanceScore.compareTo(a.relevanceScore); + if (relevanceCompare != 0) return relevanceCompare; + + // Then by date (newer first) + return b.timestamp.compareTo(a.timestamp); + }); + + // Limit results + final limitedResults = results.take(maxResults).toList(); + + return ConversationSearchResults( + query: query, + results: limitedResults, + totalMatches: results.length, + searchDuration: DateTime.now().difference(DateTime.now()), + ); + } + + /// Search within a single conversation + Future> _searchInConversation({ + required Conversation conversation, + required String query, + required ConversationSearchOptions options, + }) async { + final matches = []; + + // Search in conversation title + if (options.searchTitles && _containsQuery(conversation.title, query)) { + matches.add( + ConversationSearchMatch( + conversationId: conversation.id, + conversationTitle: conversation.title, + matchType: SearchMatchType.title, + snippet: conversation.title, + highlightedSnippet: _highlightQuery(conversation.title, query), + relevanceScore: _calculateTitleRelevance(conversation.title, query), + timestamp: conversation.updatedAt, + ), + ); + } + + // Search in messages + if (options.searchMessages) { + final messageMatches = await _searchInMessages( + conversation: conversation, + query: query, + options: options, + ); + matches.addAll(messageMatches); + } + + // Search in tags + if (options.searchTags) { + for (final tag in conversation.tags) { + if (_containsQuery(tag, query)) { + matches.add( + ConversationSearchMatch( + conversationId: conversation.id, + conversationTitle: conversation.title, + matchType: SearchMatchType.tag, + snippet: tag, + highlightedSnippet: _highlightQuery(tag, query), + relevanceScore: _calculateTagRelevance(tag, query), + timestamp: conversation.updatedAt, + additionalInfo: {'tag': tag}, + ), + ); + } + } + } + + return matches; + } + + /// Search within messages of a conversation + Future> _searchInMessages({ + required Conversation conversation, + required String query, + required ConversationSearchOptions options, + }) async { + final matches = []; + + for (int i = 0; i < conversation.messages.length; i++) { + final message = conversation.messages[i]; + + // Skip system messages if not enabled + if (!options.includeSystemMessages && message.role == 'system') { + continue; + } + + // Filter by role if specified + if (options.roleFilter != null && message.role != options.roleFilter) { + continue; + } + + // Check if message contains query + if (_containsQuery(message.content, query)) { + final snippet = _extractSnippet(message.content, query); + final contextMessages = _getContextMessages(conversation.messages, i); + + matches.add( + ConversationSearchMatch( + conversationId: conversation.id, + conversationTitle: conversation.title, + messageId: message.id, + matchType: SearchMatchType.message, + snippet: snippet, + highlightedSnippet: _highlightQuery(snippet, query), + relevanceScore: _calculateMessageRelevance(message.content, query), + timestamp: message.timestamp, + messageRole: message.role, + messageIndex: i, + contextMessages: contextMessages, + ), + ); + } + } + + return matches; + } + + /// Extract relevant snippet around the query match + String _extractSnippet(String content, String query) { + const maxSnippetLength = 200; + final queryIndex = content.toLowerCase().indexOf(query); + + if (queryIndex == -1) { + return content.substring(0, maxSnippetLength.clamp(0, content.length)); + } + + // Calculate snippet bounds + final start = (queryIndex - 50).clamp(0, content.length); + final end = (queryIndex + query.length + 50).clamp(0, content.length); + + String snippet = content.substring(start, end); + + // Add ellipsis if needed + if (start > 0) snippet = '...$snippet'; + if (end < content.length) snippet = '$snippet...'; + + return snippet; + } + + /// Get context messages around a matched message + List _getContextMessages(List messages, int index) { + final start = (index - contextLines).clamp(0, messages.length); + final end = (index + contextLines + 1).clamp(0, messages.length); + return messages.sublist(start, end); + } + + /// Highlight query matches in text + String _highlightQuery(String text, String query) { + if (query.isEmpty) return text; + + final regex = RegExp(RegExp.escape(query), caseSensitive: false); + return text.replaceAllMapped(regex, (match) { + return '${match.group(0)}'; + }); + } + + /// Check if text contains the query + bool _containsQuery(String text, String query) { + return text.toLowerCase().contains(query); + } + + /// Calculate relevance score for title matches + double _calculateTitleRelevance(String title, String query) { + final titleLower = title.toLowerCase(); + final queryLower = query.toLowerCase(); + + // Exact match gets highest score + if (titleLower == queryLower) return 100.0; + + // Title starts with query gets high score + if (titleLower.startsWith(queryLower)) return 90.0; + + // Title contains query as whole word gets medium score + if (RegExp( + r'\b' + RegExp.escape(queryLower) + r'\b', + ).hasMatch(titleLower)) { + return 70.0; + } + + // Partial match gets lower score + return 50.0; + } + + /// Calculate relevance score for message matches + double _calculateMessageRelevance(String content, String query) { + final contentLower = content.toLowerCase(); + final queryLower = query.toLowerCase(); + + // Count occurrences + final occurrences = queryLower.allMatches(contentLower).length; + + // Base score for containing the query + double score = 30.0; + + // Bonus for multiple occurrences + score += (occurrences - 1) * 10.0; + + // Bonus for whole word matches + if (RegExp( + r'\b' + RegExp.escape(queryLower) + r'\b', + ).hasMatch(contentLower)) { + score += 20.0; + } + + // Penalty for very long messages (relevance dilution) + if (content.length > 1000) { + score *= 0.8; + } + + return score.clamp(0.0, 100.0); + } + + /// Calculate relevance score for tag matches + double _calculateTagRelevance(String tag, String query) { + final tagLower = tag.toLowerCase(); + final queryLower = query.toLowerCase(); + + // Exact match gets highest score + if (tagLower == queryLower) return 80.0; + + // Tag starts with query gets high score + if (tagLower.startsWith(queryLower)) return 70.0; + + // Partial match gets medium score + return 50.0; + } +} + +/// Search options for conversation search +@immutable +class ConversationSearchOptions { + final bool searchTitles; + final bool searchMessages; + final bool searchTags; + final bool includeSystemMessages; + final String? roleFilter; // 'user', 'assistant', 'system' + final DateTime? dateFrom; + final DateTime? dateTo; + final bool caseSensitive; + + const ConversationSearchOptions({ + this.searchTitles = true, + this.searchMessages = true, + this.searchTags = true, + this.includeSystemMessages = false, + this.roleFilter, + this.dateFrom, + this.dateTo, + this.caseSensitive = false, + }); + + ConversationSearchOptions copyWith({ + bool? searchTitles, + bool? searchMessages, + bool? searchTags, + bool? includeSystemMessages, + String? roleFilter, + DateTime? dateFrom, + DateTime? dateTo, + bool? caseSensitive, + }) { + return ConversationSearchOptions( + searchTitles: searchTitles ?? this.searchTitles, + searchMessages: searchMessages ?? this.searchMessages, + searchTags: searchTags ?? this.searchTags, + includeSystemMessages: + includeSystemMessages ?? this.includeSystemMessages, + roleFilter: roleFilter ?? this.roleFilter, + dateFrom: dateFrom ?? this.dateFrom, + dateTo: dateTo ?? this.dateTo, + caseSensitive: caseSensitive ?? this.caseSensitive, + ); + } +} + +/// Search results container +@immutable +class ConversationSearchResults { + final String query; + final List results; + final int totalMatches; + final Duration searchDuration; + + const ConversationSearchResults({ + required this.query, + required this.results, + required this.totalMatches, + required this.searchDuration, + }); + + factory ConversationSearchResults.empty() { + return ConversationSearchResults( + query: '', + results: const [], + totalMatches: 0, + searchDuration: Duration.zero, + ); + } + + bool get isEmpty => results.isEmpty; + bool get isNotEmpty => results.isNotEmpty; + int get length => results.length; +} + +/// Individual search match +@immutable +class ConversationSearchMatch { + final String conversationId; + final String conversationTitle; + final String? messageId; + final SearchMatchType matchType; + final String snippet; + final String highlightedSnippet; + final double relevanceScore; + final DateTime timestamp; + final String? messageRole; + final int? messageIndex; + final List? contextMessages; + final Map? additionalInfo; + + const ConversationSearchMatch({ + required this.conversationId, + required this.conversationTitle, + this.messageId, + required this.matchType, + required this.snippet, + required this.highlightedSnippet, + required this.relevanceScore, + required this.timestamp, + this.messageRole, + this.messageIndex, + this.contextMessages, + this.additionalInfo, + }); +} + +/// Types of search matches +enum SearchMatchType { title, message, tag } + +/// Provider for conversation search service +final conversationSearchServiceProvider = Provider(( + ref, +) { + return ConversationSearchService(); +}); + +/// Provider for search results +final conversationSearchResultsProvider = + StateProvider((ref) { + return null; + }); + +/// Provider for search options +final searchOptionsProvider = StateProvider((ref) { + return const ConversationSearchOptions(); +}); diff --git a/lib/features/chat/services/file_attachment_service.dart b/lib/features/chat/services/file_attachment_service.dart new file mode 100644 index 0000000..cb99d40 --- /dev/null +++ b/lib/features/chat/services/file_attachment_service.dart @@ -0,0 +1,433 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart' as path; +import '../../../core/services/api_service.dart'; +import '../../../core/providers/app_providers.dart'; + +class FileAttachmentService { + final ApiService _apiService; + final ImagePicker _imagePicker = ImagePicker(); + + FileAttachmentService(this._apiService); + + // Pick files from device + Future> pickFiles({ + bool allowMultiple = true, + List? allowedExtensions, + }) async { + try { + final result = await FilePicker.platform.pickFiles( + allowMultiple: allowMultiple, + type: allowedExtensions != null ? FileType.custom : FileType.any, + allowedExtensions: allowedExtensions, + ); + + if (result == null || result.files.isEmpty) { + return []; + } + + return result.files + .where((file) => file.path != null) + .map((file) => File(file.path!)) + .toList(); + } catch (e) { + throw Exception('Failed to pick files: $e'); + } + } + + // Pick image from gallery + Future pickImage() async { + try { + final XFile? image = await _imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: 85, + ); + + if (image == null) return null; + return File(image.path); + } catch (e) { + throw Exception('Failed to pick image: $e'); + } + } + + // Take photo from camera + Future takePhoto() async { + try { + final XFile? photo = await _imagePicker.pickImage( + source: ImageSource.camera, + imageQuality: 85, + ); + + if (photo == null) return null; + return File(photo.path); + } catch (e) { + throw Exception('Failed to take photo: $e'); + } + } + + // Compress image similar to OpenWebUI's implementation + Future compressImage( + String imageDataUrl, + int? maxWidth, + int? maxHeight, + ) async { + try { + // Decode base64 data + final data = imageDataUrl.split(',')[1]; + final bytes = base64Decode(data); + + // Decode image + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final image = frame.image; + + int width = image.width; + int height = image.height; + + // Calculate new dimensions maintaining aspect ratio + if (maxWidth != null && maxHeight != null) { + if (width <= maxWidth && height <= maxHeight) { + return imageDataUrl; // No compression needed + } + + if (width / height > maxWidth / maxHeight) { + height = ((maxWidth * height) / width).round(); + width = maxWidth; + } else { + width = ((maxHeight * width) / height).round(); + height = maxHeight; + } + } else if (maxWidth != null) { + if (width <= maxWidth) { + return imageDataUrl; // No compression needed + } + height = ((maxWidth * height) / width).round(); + width = maxWidth; + } else if (maxHeight != null) { + if (height <= maxHeight) { + return imageDataUrl; // No compression needed + } + width = ((maxHeight * width) / height).round(); + height = maxHeight; + } + + // Create compressed image + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint(), + ); + + final picture = recorder.endRecording(); + final compressedImage = await picture.toImage(width, height); + final byteData = await compressedImage.toByteData( + format: ui.ImageByteFormat.png, + ); + final compressedBytes = byteData!.buffer.asUint8List(); + + // Convert back to data URL + final compressedBase64 = base64Encode(compressedBytes); + return 'data:image/png;base64,$compressedBase64'; + } catch (e) { + debugPrint('DEBUG: Image compression failed: $e'); + return imageDataUrl; // Return original if compression fails + } + } + + // Convert image file to base64 data URL with compression + Future convertImageToDataUrl( + File imageFile, { + bool enableCompression = false, + int? maxWidth, + int? maxHeight, + }) async { + try { + debugPrint('DEBUG: Converting image to data URL: ${imageFile.path}'); + + // Read the file as bytes + final bytes = await imageFile.readAsBytes(); + + // Determine MIME type based on file extension + final ext = path.extension(imageFile.path).toLowerCase(); + String mimeType = 'image/png'; // default + + if (ext == '.jpg' || ext == '.jpeg') { + mimeType = 'image/jpeg'; + } else if (ext == '.gif') { + mimeType = 'image/gif'; + } else if (ext == '.webp') { + mimeType = 'image/webp'; + } + + // Convert to base64 + final base64String = base64Encode(bytes); + String dataUrl = 'data:$mimeType;base64,$base64String'; + + // Apply compression if enabled + if (enableCompression && (maxWidth != null || maxHeight != null)) { + dataUrl = await compressImage(dataUrl, maxWidth, maxHeight); + } + + debugPrint( + 'DEBUG: Image converted to data URL with MIME type: $mimeType', + ); + return dataUrl; + } catch (e) { + debugPrint('DEBUG: Failed to convert image to data URL: $e'); + return null; + } + } + + // Upload file with progress tracking + Stream uploadFile(File file) async* { + debugPrint('DEBUG: Starting file upload for: ${file.path}'); + try { + final fileName = path.basename(file.path); + final fileSize = await file.length(); + + debugPrint( + 'DEBUG: File details - Name: $fileName, Size: $fileSize bytes', + ); + + yield FileUploadState( + file: file, + fileName: fileName, + fileSize: fileSize, + progress: 0.0, + status: FileUploadStatus.uploading, + ); + + // Check if this is an image file + final ext = path.extension(fileName).toLowerCase(); + final isImage = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + ].contains(ext.substring(1)); + + if (isImage) { + debugPrint( + 'DEBUG: Image file detected, converting to data URL instead of uploading', + ); + + // For images, convert to data URL instead of uploading + final dataUrl = await convertImageToDataUrl(file); + if (dataUrl != null) { + yield FileUploadState( + file: file, + fileName: fileName, + fileSize: fileSize, + progress: 1.0, + status: FileUploadStatus.completed, + fileId: dataUrl, // Use data URL as fileId for images + isImage: true, + ); + } else { + throw Exception('Failed to convert image to data URL'); + } + } else { + debugPrint('DEBUG: Non-image file, uploading to server...'); + // Upload file using the API service + final fileId = await _apiService.uploadFile(file.path, fileName); + debugPrint('DEBUG: File uploaded successfully with ID: $fileId'); + + yield FileUploadState( + file: file, + fileName: fileName, + fileSize: fileSize, + progress: 1.0, + status: FileUploadStatus.completed, + fileId: fileId, + ); + } + } catch (e) { + debugPrint('DEBUG: File upload failed: $e'); + final fileName = path.basename(file.path); + final fileSize = await file.length(); + + yield FileUploadState( + file: file, + fileName: fileName, + fileSize: fileSize, + progress: 0.0, + status: FileUploadStatus.failed, + error: e.toString(), + ); + } + } + + // Upload multiple files + Stream> uploadMultipleFiles(List files) async* { + final states = {}; + + for (final file in files) { + final uploadStream = uploadFile(file); + await for (final state in uploadStream) { + states[file.path] = state; + yield states.values.toList(); + } + } + } + + // Format file size for display + String formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + // Get file icon based on extension + String getFileIcon(String fileName) { + final ext = path.extension(fileName).toLowerCase(); + + // Documents + if (['.pdf', '.doc', '.docx'].contains(ext)) return '📄'; + if (['.xls', '.xlsx'].contains(ext)) return '📊'; + if (['.ppt', '.pptx'].contains(ext)) return '📊'; + + // Images + if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext)) return '🖼️'; + + // Code + if (['.js', '.ts', '.py', '.dart', '.java', '.cpp'].contains(ext)) { + return '💻'; + } + if (['.html', '.css', '.json', '.xml'].contains(ext)) return '🌐'; + + // Archives + if (['.zip', '.rar', '.7z', '.tar', '.gz'].contains(ext)) return '📦'; + + // Media + if (['.mp3', '.wav', '.flac', '.m4a'].contains(ext)) return '🎵'; + if (['.mp4', '.avi', '.mov', '.mkv'].contains(ext)) return '🎬'; + + return '📎'; + } +} + +// File upload state +class FileUploadState { + final File file; + final String fileName; + final int fileSize; + final double progress; + final FileUploadStatus status; + final String? fileId; + final String? error; + final bool? isImage; // Added for image files + + FileUploadState({ + required this.file, + required this.fileName, + required this.fileSize, + required this.progress, + required this.status, + this.fileId, + this.error, + this.isImage, // Added for image files + }); + + String get formattedSize { + if (fileSize < 1024) return '$fileSize B'; + if (fileSize < 1024 * 1024) { + return '${(fileSize / 1024).toStringAsFixed(1)} KB'; + } + if (fileSize < 1024 * 1024 * 1024) { + return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + String get fileIcon { + final ext = path.extension(fileName).toLowerCase(); + + // Documents + if (['.pdf', '.doc', '.docx'].contains(ext)) return '📄'; + if (['.xls', '.xlsx'].contains(ext)) return '📊'; + if (['.ppt', '.pptx'].contains(ext)) return '📊'; + + // Images + if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext)) return '🖼️'; + + // Code + if (['.js', '.ts', '.py', '.dart', '.java', '.cpp'].contains(ext)) { + return '💻'; + } + if (['.html', '.css', '.json', '.xml'].contains(ext)) return '🌐'; + + // Archives + if (['.zip', '.rar', '.7z', '.tar', '.gz'].contains(ext)) return '📦'; + + // Media + if (['.mp3', '.wav', '.flac', '.m4a'].contains(ext)) return '🎵'; + if (['.mp4', '.avi', '.mov', '.mkv'].contains(ext)) return '🎬'; + + return '📎'; + } +} + +enum FileUploadStatus { pending, uploading, completed, failed } + +// Providers +final fileAttachmentServiceProvider = Provider((ref) { + final apiService = ref.watch(apiServiceProvider); + if (apiService == null) return null; + return FileAttachmentService(apiService); +}); + +// State notifier for managing attached files +class AttachedFilesNotifier extends StateNotifier> { + AttachedFilesNotifier() : super([]); + + void addFiles(List files) { + final newStates = files + .map( + (file) => FileUploadState( + file: file, + fileName: path.basename(file.path), + fileSize: file.lengthSync(), + progress: 0.0, + status: FileUploadStatus.pending, + ), + ) + .toList(); + + state = [...state, ...newStates]; + } + + void updateFileState(String filePath, FileUploadState newState) { + state = [ + for (final fileState in state) + if (fileState.file.path == filePath) newState else fileState, + ]; + } + + void removeFile(String filePath) { + state = state + .where((fileState) => fileState.file.path != filePath) + .toList(); + } + + void clearAll() { + state = []; + } +} + +final attachedFilesProvider = + StateNotifierProvider>((ref) { + return AttachedFilesNotifier(); + }); diff --git a/lib/features/chat/services/message_batch_service.dart b/lib/features/chat/services/message_batch_service.dart new file mode 100644 index 0000000..5518fdc --- /dev/null +++ b/lib/features/chat/services/message_batch_service.dart @@ -0,0 +1,538 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/models/chat_message.dart'; +import '../../../core/models/conversation.dart'; + +/// Service for managing batch operations on messages +class MessageBatchService { + /// Export messages to various formats + Future exportMessages({ + required List messages, + required ExportFormat format, + ExportOptions? options, + }) async { + try { + final exportOptions = options ?? const ExportOptions(); + String content; + + switch (format) { + case ExportFormat.text: + content = _exportToText(messages, exportOptions); + break; + case ExportFormat.markdown: + content = _exportToMarkdown(messages, exportOptions); + break; + case ExportFormat.json: + content = _exportToJson(messages, exportOptions); + break; + case ExportFormat.csv: + content = _exportToCsv(messages, exportOptions); + break; + } + + return BatchOperationResult.success( + operation: BatchOperation.export, + data: {'content': content, 'format': format.name}, + affectedCount: messages.length, + ); + } catch (e) { + return BatchOperationResult.error( + operation: BatchOperation.export, + error: e.toString(), + ); + } + } + + /// Delete multiple messages + Future deleteMessages({ + required List messageIds, + required Conversation conversation, + }) async { + try { + final updatedMessages = conversation.messages + .where((message) => !messageIds.contains(message.id)) + .toList(); + + final updatedConversation = conversation.copyWith( + messages: updatedMessages, + updatedAt: DateTime.now(), + ); + + return BatchOperationResult.success( + operation: BatchOperation.delete, + data: {'conversation': updatedConversation}, + affectedCount: messageIds.length, + ); + } catch (e) { + return BatchOperationResult.error( + operation: BatchOperation.delete, + error: e.toString(), + ); + } + } + + /// Copy messages to clipboard or another conversation + Future copyMessages({ + required List messages, + String? targetConversationId, + CopyFormat? format, + }) async { + try { + final copyFormat = format ?? CopyFormat.markdown; + String content; + + switch (copyFormat) { + case CopyFormat.plain: + content = messages.map((m) => m.content).join('\n\n'); + break; + case CopyFormat.markdown: + content = _exportToMarkdown(messages, const ExportOptions()); + break; + case CopyFormat.json: + content = _exportToJson(messages, const ExportOptions()); + break; + } + + return BatchOperationResult.success( + operation: BatchOperation.copy, + data: { + 'content': content, + 'format': copyFormat.name, + 'targetConversation': targetConversationId, + }, + affectedCount: messages.length, + ); + } catch (e) { + return BatchOperationResult.error( + operation: BatchOperation.copy, + error: e.toString(), + ); + } + } + + /// Move messages to another conversation + Future moveMessages({ + required List messageIds, + required Conversation sourceConversation, + required Conversation targetConversation, + }) async { + try { + final messagesToMove = sourceConversation.messages + .where((message) => messageIds.contains(message.id)) + .toList(); + + final updatedSourceMessages = sourceConversation.messages + .where((message) => !messageIds.contains(message.id)) + .toList(); + + final updatedTargetMessages = [ + ...targetConversation.messages, + ...messagesToMove, + ]; + + final updatedSourceConversation = sourceConversation.copyWith( + messages: updatedSourceMessages, + updatedAt: DateTime.now(), + ); + + final updatedTargetConversation = targetConversation.copyWith( + messages: updatedTargetMessages, + updatedAt: DateTime.now(), + ); + + return BatchOperationResult.success( + operation: BatchOperation.move, + data: { + 'sourceConversation': updatedSourceConversation, + 'targetConversation': updatedTargetConversation, + }, + affectedCount: messageIds.length, + ); + } catch (e) { + return BatchOperationResult.error( + operation: BatchOperation.move, + error: e.toString(), + ); + } + } + + /// Archive multiple messages + Future archiveMessages({ + required List messageIds, + required Conversation conversation, + }) async { + try { + final updatedMessages = conversation.messages.map((message) { + if (messageIds.contains(message.id)) { + return message.copyWith( + metadata: { + ...?message.metadata, + 'archived': true, + 'archivedAt': DateTime.now().toIso8601String(), + }, + ); + } + return message; + }).toList(); + + final updatedConversation = conversation.copyWith( + messages: updatedMessages, + updatedAt: DateTime.now(), + ); + + return BatchOperationResult.success( + operation: BatchOperation.archive, + data: {'conversation': updatedConversation}, + affectedCount: messageIds.length, + ); + } catch (e) { + return BatchOperationResult.error( + operation: BatchOperation.archive, + error: e.toString(), + ); + } + } + + /// Add tags to multiple messages + Future tagMessages({ + required List messageIds, + required List tags, + required Conversation conversation, + }) async { + try { + final updatedMessages = conversation.messages.map((message) { + if (messageIds.contains(message.id)) { + final existingTags = + (message.metadata?['tags'] as List?) ?? []; + final newTags = {...existingTags, ...tags}.toList(); + + return message.copyWith( + metadata: {...?message.metadata, 'tags': newTags}, + ); + } + return message; + }).toList(); + + final updatedConversation = conversation.copyWith( + messages: updatedMessages, + updatedAt: DateTime.now(), + ); + + return BatchOperationResult.success( + operation: BatchOperation.tag, + data: {'conversation': updatedConversation}, + affectedCount: messageIds.length, + ); + } catch (e) { + return BatchOperationResult.error( + operation: BatchOperation.tag, + error: e.toString(), + ); + } + } + + /// Filter messages by criteria + List filterMessages({ + required List messages, + MessageFilter? filter, + }) { + if (filter == null) return messages; + + return messages.where((message) { + // Role filter + if (filter.roles.isNotEmpty && !filter.roles.contains(message.role)) { + return false; + } + + // Date range filter + if (filter.dateFrom != null && + message.timestamp.isBefore(filter.dateFrom!)) { + return false; + } + if (filter.dateTo != null && message.timestamp.isAfter(filter.dateTo!)) { + return false; + } + + // Content filter + if (filter.contentFilter != null && + !message.content.toLowerCase().contains( + filter.contentFilter!.toLowerCase(), + )) { + return false; + } + + // Tag filter + if (filter.tags.isNotEmpty) { + final messageTags = (message.metadata?['tags'] as List?) ?? []; + if (!filter.tags.any((tag) => messageTags.contains(tag))) { + return false; + } + } + + // Has attachments filter + if (filter.hasAttachments != null) { + final hasAttachments = message.attachmentIds?.isNotEmpty ?? false; + if (filter.hasAttachments! != hasAttachments) { + return false; + } + } + + return true; + }).toList(); + } + + // Export format implementations + String _exportToText(List messages, ExportOptions options) { + final buffer = StringBuffer(); + + if (options.includeMetadata) { + buffer.writeln('Exported on: ${DateTime.now().toIso8601String()}'); + buffer.writeln('Messages: ${messages.length}'); + buffer.writeln('${'=' * 50}\n'); + } + + for (final message in messages) { + if (options.includeTimestamps) { + buffer.writeln('[${message.timestamp.toIso8601String()}]'); + } + + buffer.writeln('${_formatRole(message.role)}: ${message.content}'); + + if (options.includeMetadata && message.metadata?.isNotEmpty == true) { + buffer.writeln('Metadata: ${message.metadata}'); + } + + buffer.writeln(); + } + + return buffer.toString(); + } + + String _exportToMarkdown(List messages, ExportOptions options) { + final buffer = StringBuffer(); + + if (options.includeMetadata) { + buffer.writeln('# Conversation Export\n'); + buffer.writeln('- **Exported on:** ${DateTime.now().toIso8601String()}'); + buffer.writeln('- **Messages:** ${messages.length}\n'); + buffer.writeln('---\n'); + } + + for (final message in messages) { + buffer.writeln('## ${_formatRole(message.role)}'); + + if (options.includeTimestamps) { + buffer.writeln('*${message.timestamp.toIso8601String()}*\n'); + } + + buffer.writeln(message.content); + buffer.writeln(); + } + + return buffer.toString(); + } + + String _exportToJson(List messages, ExportOptions options) { + final data = { + if (options.includeMetadata) ...{ + 'exportedAt': DateTime.now().toIso8601String(), + 'messageCount': messages.length, + }, + 'messages': messages + .map( + (message) => { + 'id': message.id, + 'role': message.role, + 'content': message.content, + if (options.includeTimestamps) + 'timestamp': message.timestamp.toIso8601String(), + if (message.model != null) 'model': message.model, + if (message.attachmentIds?.isNotEmpty == true) + 'attachmentIds': message.attachmentIds, + if (options.includeMetadata && + message.metadata?.isNotEmpty == true) + 'metadata': message.metadata, + }, + ) + .toList(), + }; + + return JsonEncoder.withIndent(' ').convert(data); + } + + String _exportToCsv(List messages, ExportOptions options) { + final buffer = StringBuffer(); + + // Header + final headers = ['Role', 'Content']; + if (options.includeTimestamps) headers.insert(1, 'Timestamp'); + if (options.includeMetadata) headers.add('Metadata'); + + buffer.writeln(headers.map(_escapeCsv).join(',')); + + // Data rows + for (final message in messages) { + final row = [ + message.role, + message.content.replaceAll('\n', '\\n'), + ]; + + if (options.includeTimestamps) { + row.insert(1, message.timestamp.toIso8601String()); + } + + if (options.includeMetadata) { + row.add(message.metadata?.toString() ?? ''); + } + + buffer.writeln(row.map(_escapeCsv).join(',')); + } + + return buffer.toString(); + } + + String _formatRole(String role) { + switch (role.toLowerCase()) { + case 'user': + return 'User'; + case 'assistant': + return 'Assistant'; + case 'system': + return 'System'; + default: + return role; + } + } + + String _escapeCsv(String value) { + if (value.contains(',') || value.contains('"') || value.contains('\n')) { + return '"${value.replaceAll('"', '""')}"'; + } + return value; + } +} + +/// Export formats supported by the batch service +enum ExportFormat { text, markdown, json, csv } + +/// Copy formats for clipboard operations +enum CopyFormat { plain, markdown, json } + +/// Batch operations that can be performed +enum BatchOperation { export, delete, copy, move, archive, tag } + +/// Options for export operations +@immutable +class ExportOptions { + final bool includeTimestamps; + final bool includeMetadata; + final bool includeAttachments; + + const ExportOptions({ + this.includeTimestamps = true, + this.includeMetadata = false, + this.includeAttachments = true, + }); +} + +/// Filter criteria for messages +@immutable +class MessageFilter { + final List roles; + final DateTime? dateFrom; + final DateTime? dateTo; + final String? contentFilter; + final List tags; + final bool? hasAttachments; + + const MessageFilter({ + this.roles = const [], + this.dateFrom, + this.dateTo, + this.contentFilter, + this.tags = const [], + this.hasAttachments, + }); + + MessageFilter copyWith({ + List? roles, + DateTime? dateFrom, + DateTime? dateTo, + String? contentFilter, + List? tags, + bool? hasAttachments, + }) { + return MessageFilter( + roles: roles ?? this.roles, + dateFrom: dateFrom ?? this.dateFrom, + dateTo: dateTo ?? this.dateTo, + contentFilter: contentFilter ?? this.contentFilter, + tags: tags ?? this.tags, + hasAttachments: hasAttachments ?? this.hasAttachments, + ); + } +} + +/// Result of a batch operation +@immutable +class BatchOperationResult { + final BatchOperation operation; + final bool success; + final String? error; + final Map? data; + final int affectedCount; + + const BatchOperationResult({ + required this.operation, + required this.success, + this.error, + this.data, + this.affectedCount = 0, + }); + + factory BatchOperationResult.success({ + required BatchOperation operation, + Map? data, + int affectedCount = 0, + }) { + return BatchOperationResult( + operation: operation, + success: true, + data: data, + affectedCount: affectedCount, + ); + } + + factory BatchOperationResult.error({ + required BatchOperation operation, + required String error, + }) { + return BatchOperationResult( + operation: operation, + success: false, + error: error, + ); + } +} + +/// Provider for message batch service +final messageBatchServiceProvider = Provider((ref) { + return MessageBatchService(); +}); + +/// Provider for selected messages (for batch operations) +final selectedMessagesProvider = StateProvider>((ref) { + return {}; +}); + +/// Provider for batch operation mode +final batchModeProvider = StateProvider((ref) { + return false; +}); + +/// Provider for message filter +final messageFilterProvider = StateProvider((ref) { + return null; +}); diff --git a/lib/features/chat/services/voice_input_service.dart b/lib/features/chat/services/voice_input_service.dart new file mode 100644 index 0000000..02b7dad --- /dev/null +++ b/lib/features/chat/services/voice_input_service.dart @@ -0,0 +1,220 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:record/record.dart'; +import 'dart:async'; +import 'dart:io' show Platform; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +class VoiceInputService { + final AudioRecorder _recorder = AudioRecorder(); + bool _isInitialized = false; + bool _isListening = false; + StreamController? _textStreamController; + String _currentText = ''; + // Public stream for UI waveform visualization (emits partial text length as proxy) + StreamController? _intensityController; + Stream get intensityStream => + _intensityController?.stream ?? const Stream.empty(); + Timer? _autoStopTimer; + StreamSubscription? _ampSub; + + bool get isSupportedPlatform => Platform.isAndroid || Platform.isIOS; + + Future initialize() async { + if (_isInitialized) return true; + if (!isSupportedPlatform) return false; + // Log platform for diagnostics + // ignore: avoid_print + print( + 'DEBUG: VoiceInputService initialize on platform: ' + '${Platform.isAndroid + ? 'Android' + : Platform.isIOS + ? 'iOS' + : 'Other'}', + ); + _isInitialized = true; + return true; + } + + Future checkPermissions() async { + try { + return await _recorder.hasPermission(); + } catch (_) { + return false; + } + } + + bool get isListening => _isListening; + bool get isAvailable => _isInitialized; + + Stream startListening() { + // Ensure initialized; we allow initialize to pass even if native STT unavailable + if (!_isInitialized) { + throw Exception('Voice input not initialized'); + } + + if (_isListening) { + stopListening(); + } + + _textStreamController = StreamController.broadcast(); + _currentText = ''; + _isListening = true; + + _intensityController = StreamController.broadcast(); + + // Start recording raw audio; UI or auto-timer will stop and trigger transcription via API + // ignore: avoid_print + print('DEBUG: VoiceInputService startListening'); + _startRecordingProxyIntensity(); + + // Auto-stop after 30 seconds similar to native STT behavior + _autoStopTimer?.cancel(); + _autoStopTimer = Timer(const Duration(seconds: 30), () { + if (_isListening) { + _stopListening(); + } + }); + + return _textStreamController!.stream; + } + + Future stopListening() async { + await _stopListening(); + } + + Future _stopListening() async { + if (!_isListening) return; + + _isListening = false; + // Also stop recorder if active + await _stopRecording(); + // ignore: avoid_print + print('DEBUG: VoiceInputService stopped listening'); + + _autoStopTimer?.cancel(); + _autoStopTimer = null; + _ampSub?.cancel(); + _ampSub = null; + + if (_currentText.isNotEmpty) { + _textStreamController?.add(_currentText); + } + + _textStreamController?.close(); + _textStreamController = null; + _intensityController?.close(); + _intensityController = null; + } + + void dispose() { + stopListening(); + _stopRecording(force: true); + } + + // --- Recording and intensity proxy for server transcription path --- + Future _startRecordingProxyIntensity() async { + try { + final hasMic = await _recorder.hasPermission(); + if (!hasMic) { + _textStreamController?.addError('Microphone permission not granted'); + _stopListening(); + return; + } + + // Start recording in a portable format (WAV/PCM) for best compatibility with server + final tmpDir = await getTemporaryDirectory(); + final filePath = p.join( + tmpDir.path, + 'conduit_voice_${DateTime.now().millisecondsSinceEpoch}.wav', + ); + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + numChannels: 1, + sampleRate: 16000, + bitRate: 128000, + ), + path: filePath, + ); + // ignore: avoid_print + print('DEBUG: VoiceInputService recording started at: ' + filePath); + + // Drive intensity from amplitude stream and detect silence + // Consider amplitude less than threshold as silence; stop after ~3s of continuous silence + const silenceThresholdDb = -45.0; // dBFS threshold + const silenceWindow = Duration(seconds: 3); + DateTime lastNonSilent = DateTime.now(); + + _ampSub = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 125)) + .listen((amp) { + if (!_isListening) return; + // Normalize peak power (dBFS) into 0-10 bar scale + final db = amp.current; + // Map dB [-60..0] -> [0..10] + final clamped = db.clamp(-60.0, 0.0); + final norm = ((clamped + 60.0) / 60.0) * 10.0; + _intensityController?.add(norm.round().clamp(0, 10)); + + if (db > silenceThresholdDb) { + lastNonSilent = DateTime.now(); + } else { + if (DateTime.now().difference(lastNonSilent) >= silenceWindow) { + _stopListening(); + } + } + }); + } catch (e) { + // ignore: avoid_print + print('DEBUG: VoiceInputService recording failed: $e'); + _textStreamController?.addError('Audio recording failed: $e'); + _stopListening(); + } + } + + Future _stopRecording({bool force = false}) async { + try { + if (!await _recorder.isRecording() && !force) return; + final path = await _recorder.stop(); + if (path == null) { + _textStreamController?.addError('Recording failed: no file path'); + return; + } + // ignore: avoid_print + print('DEBUG: VoiceInputService recording saved: ' + path); + // Hand off recorded file path to listeners as a special token; UI layer will upload for transcription + _textStreamController?.add('[[AUDIO_FILE_PATH]]:$path'); + } catch (e) { + _textStreamController?.addError('Stop recording error: $e'); + } + } + + // Native locales not used in server transcription mode +} + +final voiceInputServiceProvider = Provider((ref) { + return VoiceInputService(); +}); + +final voiceInputAvailableProvider = FutureProvider((ref) async { + final service = ref.watch(voiceInputServiceProvider); + if (!service.isSupportedPlatform) return false; + final initialized = await service.initialize(); + if (!initialized) return false; + final hasPermission = await service.checkPermissions(); + if (!hasPermission) return false; + return service.isAvailable; +}); + +final voiceInputStreamProvider = StreamProvider((ref) { + // Voice input stream would be initialized when needed + return const Stream.empty(); +}); + +/// Stream of crude voice intensity for waveform visuals +final voiceIntensityStreamProvider = StreamProvider((ref) { + // Connected at runtime by the UI after calling startListening + return const Stream.empty(); +}); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart new file mode 100644 index 0000000..c2065f6 --- /dev/null +++ b/lib/features/chat/views/chat_page.dart @@ -0,0 +1,2474 @@ +import 'package:flutter/material.dart'; +import '../../../core/services/navigation_service.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/widgets/optimized_list.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:io' show Platform, File; +import 'dart:async'; +import 'package:path/path.dart' as path; +import '../../../core/providers/app_providers.dart'; +import '../providers/chat_providers.dart'; + +import '../widgets/modern_chat_input.dart'; +import '../widgets/modern_message_bubble.dart'; +import '../widgets/documentation_message_widget.dart'; +import '../widgets/file_attachment_widget.dart'; +import '../services/voice_input_service.dart'; +import '../services/file_attachment_service.dart'; +import '../../navigation/views/chats_list_page.dart'; +import '../../files/views/files_page.dart'; +import '../../profile/views/profile_page.dart'; +import '../../../shared/widgets/offline_indicator.dart'; +import '../../../core/services/connectivity_service.dart'; +import '../../../core/models/chat_message.dart'; +import '../../../core/models/model.dart'; +import '../../../shared/widgets/loading_states.dart'; +import 'chat_page_helpers.dart'; +import '../../../shared/widgets/themed_dialogs.dart'; + +class ChatPage extends ConsumerStatefulWidget { + const ChatPage({super.key}); + + @override + ConsumerState createState() => _ChatPageState(); +} + +class _ChatPageState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + bool _showScrollToBottom = false; + bool _isSelectionMode = false; + final Set _selectedMessageIds = {}; + Timer? _scrollDebounceTimer; + + String _formatModelDisplayName(String name) { + var display = name.trim(); + // Prefer the segment after the last '/' + if (display.contains('/')) { + display = display.split('/').last.trim(); + } + // If an org prefix like 'OpenAI: gpt-4o' exists, use the part after ':' + if (display.contains(':')) { + final parts = display.split(':'); + display = parts.last.trim(); + } + return display; + } + + @override + void initState() { + super.initState(); + + // Listen to scroll events to show/hide scroll to bottom button + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + _scrollDebounceTimer?.cancel(); + super.dispose(); + } + + void _handleMessageSend(String text, dynamic selectedModel) async { + debugPrint('DEBUG: Starting message send process'); + debugPrint('DEBUG: Message text: $text'); + debugPrint('DEBUG: Selected model: ${selectedModel?.name ?? 'null'}'); + + if (selectedModel == null) { + debugPrint('DEBUG: No model selected'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select a model first')), + ); + } + return; + } + + final isOnline = ref.read(isOnlineProvider); + debugPrint('DEBUG: Online status: $isOnline'); + if (!isOnline) { + debugPrint('DEBUG: Offline - cannot send message'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'You\'re offline. Message will be sent when connection is restored.', + ), + backgroundColor: context.conduitTheme.warning, + ), + ); + } + // TODO: Implement message queueing for offline mode + return; + } + + try { + // Get attached files and use uploadedFileIds when sendMessage is updated to accept file IDs + final attachedFiles = ref.read(attachedFilesProvider); + debugPrint('DEBUG: Attached files count: ${attachedFiles.length}'); + + for (final file in attachedFiles) { + debugPrint( + 'DEBUG: File - Name: ${file.fileName}, Status: ${file.status}, FileId: ${file.fileId}', + ); + } + + final uploadedFileIds = attachedFiles + .where( + (file) => + file.status == FileUploadStatus.completed && + file.fileId != null, + ) + .map((file) => file.fileId!) + .toList(); + + debugPrint('DEBUG: Uploaded file IDs: $uploadedFileIds'); + + // Send message with file attachments using existing provider logic + await sendMessage( + ref, + text, + uploadedFileIds.isNotEmpty ? uploadedFileIds : null, + ); + + debugPrint('DEBUG: Message sent successfully'); + + // Clear attachments after successful send + ref.read(attachedFilesProvider.notifier).clearAll(); + debugPrint('DEBUG: Attachments cleared'); + + // Scroll to bottom after sending message (only if user was near bottom) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + // Only auto-scroll if user was already near the bottom (within 300px) + if (maxScroll - currentScroll < 300) { + _scrollToBottom(); + } + } + }); + } catch (e) { + debugPrint('DEBUG: Message send error: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Message failed to send. Please try again.'), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + } + + void _handleVoiceInput() async { + // TODO: Implement voice input functionality + final isAvailable = await ref.read(voiceInputAvailableProvider.future); + + if (!isAvailable) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Voice input unavailable. Check permissions.'), + backgroundColor: context.conduitTheme.warning, + ), + ); + return; + } + + // Show voice input dialog + if (!mounted) return; + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => _VoiceInputSheet( + onTextReceived: (text) { + if (text.isNotEmpty) { + final selectedModel = ref.read(selectedModelProvider); + if (selectedModel != null) { + _handleMessageSend(text, selectedModel); + } + } + }, + ), + ); + } + + void _handleFileAttachment() async { + // Check if selected model supports file upload + final fileUploadCapableModels = ref.read(fileUploadCapableModelsProvider); + if (fileUploadCapableModels.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Selected model does not support file upload'), + backgroundColor: context.conduitTheme.error, + ), + ); + return; + } + + final fileService = ref.read(fileAttachmentServiceProvider); + if (fileService == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('File service unavailable'))); + return; + } + + try { + final files = await fileService.pickFiles(); + if (files.isEmpty) return; + + // Validate file count + final currentFiles = ref.read(attachedFilesProvider); + if (!validateFileCount(currentFiles.length, files.length, 10)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Maximum 10 files allowed'), + backgroundColor: context.conduitTheme.error, + ), + ); + return; + } + + // Validate file sizes + for (final file in files) { + final fileSize = await file.length(); + if (!validateFileSize(fileSize, 20)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'File ${path.basename(file.path)} exceeds 20MB limit', + ), + backgroundColor: context.conduitTheme.error, + ), + ); + return; + } + } + + // Add files to the attachment list + ref.read(attachedFilesProvider.notifier).addFiles(files); + + // Start uploading files + for (final file in files) { + final uploadStream = fileService.uploadFile(file); + uploadStream.listen( + (state) { + ref + .read(attachedFilesProvider.notifier) + .updateFileState(file.path, state); + }, + onError: (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Upload failed: $error'), + backgroundColor: context.conduitTheme.error, + ), + ); + }, + ); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('File selection failed: $e'), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + + void _handleImageAttachment({bool fromCamera = false}) async { + debugPrint( + 'DEBUG: Starting image attachment process - fromCamera: $fromCamera', + ); + + // Check if selected model supports vision + final visionCapableModels = ref.read(visionCapableModelsProvider); + if (visionCapableModels.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Selected model does not support image inputs'), + backgroundColor: context.conduitTheme.error, + ), + ); + return; + } + + final fileService = ref.read(fileAttachmentServiceProvider); + if (fileService == null) { + debugPrint('DEBUG: File service is null - cannot proceed'); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('File service unavailable'))); + return; + } + + try { + debugPrint('DEBUG: Picking image...'); + final image = fromCamera + ? await fileService.takePhoto() + : await fileService.pickImage(); + if (image == null) { + debugPrint('DEBUG: No image selected'); + return; + } + + debugPrint('DEBUG: Image selected: ${image.path}'); + final imageSize = await image.length(); + debugPrint('DEBUG: Image size: $imageSize bytes'); + + // Validate file size (default 20MB limit like OpenWebUI) + if (!validateFileSize(imageSize, 20)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Image size exceeds 20MB limit'), + backgroundColor: context.conduitTheme.error, + ), + ); + return; + } + + // Validate file count (default 10 files limit like OpenWebUI) + final currentFiles = ref.read(attachedFilesProvider); + if (!validateFileCount(currentFiles.length, 1, 10)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Maximum 10 files allowed'), + backgroundColor: context.conduitTheme.error, + ), + ); + return; + } + + // Add image to the attachment list + ref.read(attachedFilesProvider.notifier).addFiles([image]); + debugPrint('DEBUG: Image added to attachment list'); + + // Start uploading image + debugPrint('DEBUG: Starting image upload...'); + final uploadStream = fileService.uploadFile(image); + uploadStream.listen( + (state) { + debugPrint( + 'DEBUG: Upload state update - Status: ${state.status}, Progress: ${state.progress}, FileId: ${state.fileId}', + ); + ref + .read(attachedFilesProvider.notifier) + .updateFileState(image.path, state); + }, + onError: (error) { + debugPrint('DEBUG: Image upload error: $error'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Image upload failed: $error'), + backgroundColor: context.conduitTheme.error, + ), + ); + }, + ); + } catch (e) { + debugPrint('DEBUG: Image attachment error: $e'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Image attachment failed: $e'), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + + void _handleNewChat() { + // Start a new chat using the existing function + startNewChat(ref); + + // Hide scroll-to-bottom button for a fresh chat + if (mounted) { + setState(() { + _showScrollToBottom = false; + }); + } + + // Show success message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('New chat started'), + duration: Duration(seconds: 2), + ), + ); + } + + void _showChatsListOverlay() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.bottomSheet), + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.modal, + ), + child: SafeArea( + top: false, + bottom: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + Expanded(child: const ChatsListPage(isOverlay: true)), + ], + ), + ), + ), + ); + } + + void _showQuickAccessMenu() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.modal), + ), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + // Hint text + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + child: Text( + 'Quick Actions', + style: AppTypography.bodySmallStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: Spacing.xs), + // Menu items + ListTile( + leading: Icon( + Platform.isIOS ? CupertinoIcons.plus : Icons.add_rounded, + color: context.conduitTheme.iconPrimary, + ), + title: Text( + 'New Chat', + style: AppTypography.bodyLargeStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + subtitle: Text( + 'Start a new conversation', + style: AppTypography.bodySmallStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + onTap: () { + Navigator.pop(context); + _handleNewChat(); + }, + ), + ListTile( + leading: Icon( + Platform.isIOS + ? CupertinoIcons.doc + : Icons.description_outlined, + color: context.conduitTheme.iconPrimary, + ), + title: Text( + 'Files', + style: AppTypography.bodyLargeStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + subtitle: Text( + 'Manage your files and documents', + style: AppTypography.bodySmallStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + onTap: () { + Navigator.pop(context); + _navigateToFiles(); + }, + ), + ListTile( + leading: Icon( + Platform.isIOS ? CupertinoIcons.person : Icons.person_outline, + color: context.conduitTheme.iconPrimary, + ), + title: Text( + 'Profile', + style: AppTypography.bodyLargeStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + subtitle: Text( + 'View and manage your profile', + style: AppTypography.bodySmallStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + onTap: () { + Navigator.pop(context); + _navigateToProfile(); + }, + ), + const SizedBox(height: Spacing.sm), + ], + ), + ), + ), + ); + } + + void _navigateToFiles() { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => const FilesPage())); + } + + void _navigateToProfile() { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => const ProfilePage())); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + + // Debounce scroll handling to reduce rebuilds + if (_scrollDebounceTimer?.isActive == true) return; + + _scrollDebounceTimer = Timer(const Duration(milliseconds: 50), () { + if (!mounted || !_scrollController.hasClients) return; + + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + + // Only show button if user has scrolled up significantly + final showButton = maxScroll > 100 && currentScroll < maxScroll - 200; + + if (showButton != _showScrollToBottom && mounted) { + setState(() { + _showScrollToBottom = showButton; + }); + } + }); + } + + void _scrollToBottom({bool smooth = true}) { + if (!_scrollController.hasClients) return; + + final maxScroll = _scrollController.position.maxScrollExtent; + if (maxScroll <= 0) return; + + if (smooth) { + _scrollController.animateTo( + maxScroll, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } else { + _scrollController.jumpTo(maxScroll); + } + } + + void _toggleSelectionMode() { + setState(() { + _isSelectionMode = !_isSelectionMode; + if (!_isSelectionMode) { + _selectedMessageIds.clear(); + } + }); + } + + void _toggleMessageSelection(String messageId) { + setState(() { + if (_selectedMessageIds.contains(messageId)) { + _selectedMessageIds.remove(messageId); + if (_selectedMessageIds.isEmpty) { + _isSelectionMode = false; + } + } else { + _selectedMessageIds.add(messageId); + } + }); + } + + // TODO: Implement select all functionality when needed + // void _selectAllMessages() { + // final messages = ref.read(chatMessagesProvider); + // setState(() { + // _selectedMessageIds.clear(); + // _selectedMessageIds.addAll(messages.map((m) => m.id)); + // }); + // } + + void _clearSelection() { + setState(() { + _selectedMessageIds.clear(); + _isSelectionMode = false; + }); + } + + List _getSelectedMessages() { + final messages = ref.read(chatMessagesProvider); + return messages.where((m) => _selectedMessageIds.contains(m.id)).toList(); + } + + Widget _buildMessagesList(ThemeData theme) { + // Use select to watch only the messages list to reduce rebuilds + final messages = ref.watch( + chatMessagesProvider.select((messages) => messages), + ); + final isLoadingConversation = ref.watch(isLoadingConversationProvider); + + if (isLoadingConversation && messages.isEmpty) { + // Show message skeletons during conversation load + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB( + Spacing.lg, + Spacing.xl, + Spacing.lg, + Spacing.lg, + ), + itemCount: 6, + itemBuilder: (context, index) { + final isUser = index.isOdd; + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: Spacing.md), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.82, + ), + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: isUser + ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.15) + : context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular( + AppBorderRadius.messageBubble, + ), + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.messageBubble, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, + width: index % 3 == 0 ? 140 : 220, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ).animate().shimmer(duration: AnimationDuration.slow), + const SizedBox(height: Spacing.xs), + Container( + height: 14, + width: double.infinity, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ).animate().shimmer(duration: AnimationDuration.slow), + if (index % 3 != 0) ...[ + const SizedBox(height: Spacing.xs), + Container( + height: 14, + width: index % 2 == 0 ? 180 : 120, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ).animate().shimmer(duration: AnimationDuration.slow), + ], + ], + ), + ), + ); + }, + ); + } + + if (messages.isEmpty) { + return _buildEmptyState(theme); + } + + return OptimizedList( + scrollController: _scrollController, + items: messages, + padding: const EdgeInsets.fromLTRB( + Spacing.lg, + Spacing.xl, + Spacing.lg, + Spacing.lg, + ), + itemBuilder: (context, message, index) { + final isUser = message.role == 'user'; + final isStreaming = message.isStreaming; + + final isSelected = _selectedMessageIds.contains(message.id); + + // Wrap message in selection container if in selection mode + Widget messageWidget; + + // Use documentation style for assistant messages, bubble for user messages + if (isUser) { + messageWidget = ModernMessageBubble( + key: ValueKey('user-${message.id}'), + message: message, + isUser: isUser, + isStreaming: isStreaming, + modelName: message.model, + onCopy: () => _copyMessage(message.content), + onEdit: () => _editMessage(message), + onRegenerate: () => _regenerateMessage(message), + onLike: () => _likeMessage(message), + onDislike: () => _dislikeMessage(message), + ); + } else { + messageWidget = DocumentationMessageWidget( + key: ValueKey('assistant-${message.id}'), + message: message, + isUser: isUser, + isStreaming: isStreaming, + modelName: message.model, + onCopy: () => _copyMessage(message.content), + onEdit: () => _editMessage(message), + onRegenerate: () => _regenerateMessage(message), + onLike: () => _likeMessage(message), + onDislike: () => _dislikeMessage(message), + ); + } + + // Add selection functionality if in selection mode + if (_isSelectionMode) { + return _SelectableMessageWrapper( + isSelected: isSelected, + onTap: () => _toggleMessageSelection(message.id), + onLongPress: () { + if (!_isSelectionMode) { + _toggleSelectionMode(); + _toggleMessageSelection(message.id); + } + }, + child: messageWidget, + ); + } else { + return GestureDetector( + onLongPress: () { + _toggleSelectionMode(); + _toggleMessageSelection(message.id); + }, + child: messageWidget, + ); + } + }, + ); + } + + void _copyMessage(String content) { + Clipboard.setData(ClipboardData(text: content)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + } + + void _regenerateMessage(dynamic message) async { + final selectedModel = ref.read(selectedModelProvider); + if (selectedModel == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select a model first')), + ); + return; + } + + // Find the user message that prompted this assistant response + final messages = ref.read(chatMessagesProvider); + final messageIndex = messages.indexOf(message); + + if (messageIndex <= 0 || messages[messageIndex - 1].role != 'user') { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cannot regenerate this message')), + ); + return; + } + + try { + // Remove the assistant message we want to regenerate + ref.read(chatMessagesProvider.notifier).removeLastMessage(); + + // Resend the previous user message to get a new response + final userMessage = messages[messageIndex - 1]; + await sendMessage(ref, userMessage.content, null); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Regenerating...'), + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to regenerate message: $e'), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + } + + void _editMessage(dynamic message) async { + if (message.role != 'user') { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Only user messages can be edited')), + ); + return; + } + + final controller = TextEditingController(text: message.content); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: context.conduitTheme.surfaceBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.dialog), + ), + title: Text( + 'Edit Message', + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + content: TextField( + controller: controller, + style: TextStyle(color: context.conduitTheme.textPrimary), + maxLines: null, + decoration: InputDecoration( + hintText: 'Enter your message', + hintStyle: TextStyle(color: context.conduitTheme.inputPlaceholder), + border: OutlineInputBorder( + borderSide: BorderSide(color: context.conduitTheme.inputBorder), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: context.conduitTheme.inputBorder), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: context.conduitTheme.buttonPrimary), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Cancel', + style: TextStyle(color: context.conduitTheme.textSecondary), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + style: TextButton.styleFrom( + foregroundColor: context.conduitTheme.buttonPrimary, + ), + child: const Text('Save'), + ), + ], + ), + ); + + if (result != null && result.isNotEmpty && result != message.content) { + try { + // Find the message index and remove all messages after it + final messages = ref.read(chatMessagesProvider); + final messageIndex = messages.indexOf(message); + + if (messageIndex >= 0) { + // Remove messages from this point onwards + final messagesToKeep = messages.take(messageIndex).toList(); + ref.read(chatMessagesProvider.notifier).setMessages(messagesToKeep); + + // Send the edited message + final selectedModel = ref.read(selectedModelProvider); + if (selectedModel != null) { + await sendMessage(ref, result, null); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Message updated'), + duration: Duration(seconds: 2), + ), + ); + } + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to edit message: $e'), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + } + + controller.dispose(); + } + + void _likeMessage(dynamic message) { + // TODO: Implement message liking + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Message liked!'))); + } + + void _dislikeMessage(dynamic message) { + // TODO: Implement message disliking + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Message disliked!'))); + } + + Widget _buildEmptyState(ThemeData theme) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Minimal, clean empty state + Container( + width: Spacing.xxl + Spacing.xxxl, + height: Spacing.xxl + Spacing.xxxl, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + context.conduitTheme.buttonPrimary, + context.conduitTheme.buttonPrimary.withValues( + alpha: 0.8, + ), + ], + ), + borderRadius: BorderRadius.circular(AppBorderRadius.round), + boxShadow: ConduitShadows.glow, + ), + child: Icon( + Platform.isIOS ? CupertinoIcons.chat_bubble_2 : Icons.chat, + size: Spacing.xxxl - Spacing.xs, + color: context.conduitTheme.textInverse, + ), + ) + .animate() + .scale(duration: const Duration(milliseconds: 300)) + .then() + .shimmer(duration: const Duration(milliseconds: 1200)), + + const SizedBox(height: Spacing.xl), + + Text( + 'Start a conversation', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: context.conduitTheme.textPrimary, + ), + ).animate().fadeIn(delay: const Duration(milliseconds: 150)), + + const SizedBox(height: Spacing.sm), + + Text( + 'Type below to begin', + style: theme.textTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w400, + ), + ).animate().fadeIn(delay: const Duration(milliseconds: 300)), + ], + ), + ), + ); + } + + // Removed detailed help items from chat page; guidance now lives in Onboarding + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // Use select to watch only connectivity status to reduce rebuilds + final isOnline = ref.watch(isOnlineProvider.select((status) => status)); + + // Use select to watch only the selected model to reduce rebuilds + final selectedModel = ref.watch( + selectedModelProvider.select((model) => model), + ); + + return ErrorBoundary( + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) async { + if (didPop) return; + + // Check if there's unsaved content + final messages = ref.read(chatMessagesProvider); + if (messages.isNotEmpty) { + final shouldPop = await NavigationService.confirmNavigation( + title: 'Leave Chat?', + message: 'Your conversation will be saved.', + confirmText: 'Leave', + cancelText: 'Stay', + ); + if (shouldPop && context.mounted) { + final canPopNavigator = Navigator.of(context).canPop(); + if (canPopNavigator) { + Navigator.of(context).pop(); + } else { + SystemNavigator.pop(); + } + } + } else if (context.mounted) { + final canPopNavigator = Navigator.of(context).canPop(); + if (canPopNavigator) { + Navigator.of(context).pop(); + } else { + SystemNavigator.pop(); + } + } + }, + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: Elevation.none, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + toolbarHeight: kToolbarHeight, + titleSpacing: 0.0, + leading: _isSelectionMode + ? IconButton( + icon: Icon( + Platform.isIOS ? CupertinoIcons.xmark : Icons.close, + color: context.conduitTheme.textPrimary, + size: IconSize.appBar, + ), + onPressed: _clearSelection, + ) + : GestureDetector( + onTap: () { + _showChatsListOverlay(); + }, + onLongPress: () { + HapticFeedback.mediumImpact(); + _showQuickAccessMenu(); + }, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Icon( + Platform.isIOS + ? CupertinoIcons.line_horizontal_3 + : Icons.menu, + color: context.conduitTheme.textPrimary, + size: IconSize.appBar, + ), + ), + ), + title: _isSelectionMode + ? Text( + '${_selectedMessageIds.length} selected', + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ) + : selectedModel != null + ? GestureDetector( + onTap: () { + final modelsAsync = ref.read(modelsProvider); + modelsAsync.whenData( + (models) => _showModelDropdown(context, ref, models), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatModelDisplayName(selectedModel.name), + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.keyboard_arrow_down, + color: context.conduitTheme.iconSecondary, + size: IconSize.small, + ), + ), + ], + ), + ) + : GestureDetector( + onTap: () { + final modelsAsync = ref.read(modelsProvider); + modelsAsync.whenData( + (models) => _showModelDropdown(context, ref, models), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Choose Model', + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.keyboard_arrow_down, + color: context.conduitTheme.iconSecondary, + size: IconSize.small, + ), + ), + ], + ), + ), + actions: [ + if (!_isSelectionMode) ...[ + IconButton( + icon: Icon( + Platform.isIOS + ? CupertinoIcons.bubble_left + : Icons.chat_bubble_outline, + color: context.conduitTheme.textPrimary, + size: IconSize.appBar, + ), + onPressed: _handleNewChat, + tooltip: 'New Chat', + ), + ] else ...[ + IconButton( + icon: Icon( + Platform.isIOS ? CupertinoIcons.delete : Icons.delete, + color: context.conduitTheme.error, + size: IconSize.appBar, + ), + onPressed: _deleteSelectedMessages, + ), + ], + ], + ), + body: Stack( + children: [ + Column( + children: [ + // Server banners + Consumer( + builder: (context, ref, child) { + final banners = ref.watch(serverBannersProvider); + return banners.when( + data: (bannerList) => bannerList.isNotEmpty + ? Container( + color: theme.colorScheme.primaryContainer + .withValues(alpha: Alpha.badgeBackground), + child: Column( + children: bannerList + .take(1) + .map( + (banner) => + _buildChatBanner(context, banner), + ) + .toList(), + ), + ) + : const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ); + }, + ), + + // Messages Area with pull-to-refresh + Expanded( + child: ConduitRefreshIndicator( + onRefresh: () async { + // Reload active conversation messages from server + final api = ref.read(apiServiceProvider); + final active = ref.read(activeConversationProvider); + if (api != null && active != null) { + try { + final full = await api.getConversation(active.id); + ref + .read(activeConversationProvider.notifier) + .state = + full; + } catch (_) {} + } + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + FocusManager.instance.primaryFocus?.unfocus(), + child: _buildMessagesList(theme), + ), + ), + ), + + // File attachments + const FileAttachmentWidget(), + + // Offline indicator + const ChatOfflineOverlay(), + + // Modern Input (root matches input background including safe area) + ModernChatInput( + enabled: selectedModel != null && isOnline, + onSendMessage: (text) => + _handleMessageSend(text, selectedModel), + onVoiceInput: _handleVoiceInput, + onFileAttachment: _handleFileAttachment, + onImageAttachment: _handleImageAttachment, + onCameraCapture: () => + _handleImageAttachment(fromCamera: true), + ), + ], + ), + + // Floating Scroll to Bottom Button (only if there are messages) + if (_showScrollToBottom && + ref.watch(chatMessagesProvider).isNotEmpty) + Positioned( + bottom: + Spacing.xxl + + Spacing + .xxxl, // Position higher to avoid overlapping chat input + right: Spacing.lg, + child: FloatingActionButton( + onPressed: _scrollToBottom, + backgroundColor: context.conduitTheme.buttonPrimary, + foregroundColor: context.conduitTheme.buttonPrimaryText, + elevation: Elevation.medium, + child: Icon( + Platform.isIOS + ? CupertinoIcons.arrow_down + : Icons.keyboard_arrow_down, + size: IconSize.large, + ), + ), + ) + .animate() + .fadeIn(duration: AnimationDuration.microInteraction) + .slideY( + begin: AnimationValues.slideInFromBottom.dy, + end: AnimationValues.slideCenter.dy, + duration: AnimationDuration.microInteraction, + curve: AnimationCurves.microInteraction, + ), + ], + ), + ), // Scaffold + ), // PopScope + ); // ErrorBoundary + } + + void _showModelDropdown( + BuildContext context, + WidgetRef ref, + List models, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _ModelSelectorSheet(models: models, ref: ref), + ); + } + + // TODO: Implement chat options when needed + // void _showChatOptions() { + // ScaffoldMessenger.of( + // context, + // ).showSnackBar(const SnackBar(content: Text('Chat options coming soon!'))); + // } + + void _deleteSelectedMessages() { + final selectedMessages = _getSelectedMessages(); + if (selectedMessages.isEmpty) return; + + ThemedDialogs.confirm( + context, + title: 'Delete Messages', + message: 'Delete ${selectedMessages.length} messages?', + confirmText: 'Delete', + isDestructive: true, + ).then((confirmed) async { + if (confirmed == true) { + // TODO: Implement message removal + // for (final selectedMessage in selectedMessages) { + // ref.read(chatMessagesProvider.notifier).removeMessage(selectedMessage.id); + // } + _clearSelection(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Messages removed'), + duration: Duration(seconds: 2), + ), + ); + } + } + }); + } +} + +class _ModelSelectorSheet extends ConsumerStatefulWidget { + final List models; + final WidgetRef ref; + + const _ModelSelectorSheet({required this.models, required this.ref}); + + @override + ConsumerState<_ModelSelectorSheet> createState() => + _ModelSelectorSheetState(); +} + +class _ModelSelectorSheetState extends ConsumerState<_ModelSelectorSheet> { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + List _filteredModels = []; + Timer? _searchDebounce; + // No capability filters + // Grid view removed + + Widget _capabilityChip({required IconData icon, required String label}) { + return Container( + margin: const EdgeInsets.only(right: Spacing.xs), + padding: const EdgeInsets.symmetric(horizontal: Spacing.xs, vertical: 2), + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppBorderRadius.chip), + border: Border.all( + color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: context.conduitTheme.buttonPrimary), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + // Removed filter toggle UI and logic + + @override + void initState() { + super.initState(); + _filteredModels = widget.models; + } + + @override + void dispose() { + _searchController.dispose(); + _searchDebounce?.cancel(); + super.dispose(); + } + + void _filterModels(String query) { + // Debounce for fast search + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 160), () { + setState(() { + _searchQuery = query.toLowerCase(); + Iterable list = widget.models; + if (_searchQuery.isNotEmpty) { + list = list.where((model) { + return model.name.toLowerCase().contains(_searchQuery) || + model.id.toLowerCase().contains(_searchQuery); + }); + } + // No capability filters + _filteredModels = list.toList(); + }); + }); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.75, + maxChildSize: 0.92, + minChildSize: 0.45, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.bottomSheet), + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.modal, + ), + child: SafeArea( + top: false, + bottom: true, + child: Padding( + padding: const EdgeInsets.all(Spacing.bottomSheetPadding), + child: Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only( + top: Spacing.sm, + bottom: Spacing.md, + ), + width: Spacing.xxl, + height: Spacing.xs, + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Row( + children: [ + Expanded( + child: Text( + 'Choose Model', + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.headlineMedium, + fontWeight: FontWeight.w600, + ), + ), + ), + // Removed capabilities legend to reduce icon noise + ], + ), + ), + + // Search field + Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: TextField( + controller: _searchController, + style: TextStyle(color: context.conduitTheme.textPrimary), + decoration: InputDecoration( + hintText: 'Search...', + hintStyle: TextStyle( + color: context.conduitTheme.inputPlaceholder, + ), + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search, + color: context.conduitTheme.iconSecondary, + ), + filled: true, + fillColor: context.conduitTheme.inputBackground, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + borderSide: BorderSide( + color: context.conduitTheme.buttonPrimary, + width: 1, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.md, + ), + ), + onChanged: _filterModels, + ), + ), + + // Removed capability filters + const SizedBox(height: Spacing.sm), + + // Models list + Expanded( + child: Scrollbar( + controller: scrollController, + child: _filteredModels.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.search_circle + : Icons.search_off, + size: 48, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(height: Spacing.md), + Text( + 'No results', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodyLarge, + ), + ), + ], + ), + ) + : ListView.builder( + controller: scrollController, + padding: EdgeInsets.zero, + itemCount: _filteredModels.length, + itemBuilder: (context, index) { + final model = _filteredModels[index]; + final isSelected = + widget.ref + .watch(selectedModelProvider) + ?.id == + model.id; + + return _buildModelListTile( + model: model, + isSelected: isSelected, + onTap: () { + HapticFeedback.selectionClick(); + widget.ref + .read( + selectedModelProvider.notifier, + ) + .state = + model; + Navigator.pop(context); + }, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + // Layout toggle removed + + // Removed grid card renderer (grid view removed) + + bool _modelSupportsReasoning(Model model) { + // Only rely on supported_parameters containing 'reasoning' + final params = model.supportedParameters ?? const []; + return params.any((p) => p.toLowerCase().contains('reasoning')); + } + + // Removed: _capabilityBadge no longer used + + // Removed: _capabilityPlusBadge no longer used + + Widget _buildModelListTile({ + required Model model, + required bool isSelected, + required VoidCallback onTap, + }) { + return PressableScale( + onTap: onTap, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Container( + margin: const EdgeInsets.only(bottom: Spacing.md), + decoration: BoxDecoration( + gradient: isSelected + ? LinearGradient( + colors: [ + context.conduitTheme.buttonPrimary.withValues(alpha: 0.2), + context.conduitTheme.buttonPrimary.withValues(alpha: 0.1), + ], + ) + : null, + color: isSelected + ? null + : context.conduitTheme.surfaceBackground.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.5) + : context.conduitTheme.dividerColor, + width: BorderWidth.regular, + ), + boxShadow: isSelected ? ConduitShadows.card : null, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary.withValues( + alpha: 0.15, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Icon( + Platform.isIOS ? CupertinoIcons.cube : Icons.psychology, + color: context.conduitTheme.buttonPrimary, + size: 16, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + model.name, + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: AppTypography.bodyMedium, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: Spacing.xs), + Row( + children: [ + if (model.isMultimodal) + _capabilityChip( + icon: Platform.isIOS + ? CupertinoIcons.photo + : Icons.image, + label: 'Multimodal', + ), + if (_modelSupportsReasoning(model)) + _capabilityChip( + icon: Platform.isIOS + ? CupertinoIcons.lightbulb + : Icons.psychology_alt, + label: 'Reasoning', + ), + ], + ), + ], + ), + ), + const SizedBox(width: Spacing.md), + AnimatedOpacity( + opacity: isSelected ? 1 : 0.6, + duration: AnimationDuration.fast, + child: Container( + padding: const EdgeInsets.all(Spacing.xxs), + decoration: BoxDecoration( + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.surfaceBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues( + alpha: 0.6, + ) + : context.conduitTheme.dividerColor, + ), + ), + child: Icon( + isSelected + ? (Platform.isIOS + ? CupertinoIcons.check_mark + : Icons.check) + : (Platform.isIOS ? CupertinoIcons.add : Icons.add), + color: isSelected + ? context.conduitTheme.textInverse + : context.conduitTheme.iconSecondary, + size: 14, + ), + ), + ), + ], + ), + ), + ), + ).animate().fadeIn(duration: AnimationDuration.microInteraction); + } + + // Intentionally left blank placeholder for nested helper; moved to top-level below +} + +class _VoiceInputSheet extends ConsumerStatefulWidget { + final Function(String) onTextReceived; + + const _VoiceInputSheet({required this.onTextReceived}); + + @override + ConsumerState<_VoiceInputSheet> createState() => _VoiceInputSheetState(); +} + +class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> { + bool _isListening = false; + String _recognizedText = ''; + late VoiceInputService _voiceService; + StreamSubscription? _intensitySub; + int _intensity = 0; + StreamSubscription? _textSub; + int _elapsedSeconds = 0; + Timer? _elapsedTimer; + bool _isTranscribing = false; + String _languageTag = 'en'; + + @override + void initState() { + super.initState(); + _voiceService = ref.read(voiceInputServiceProvider); + try { + _languageTag = WidgetsBinding.instance.platformDispatcher.locale + .toLanguageTag() + .split(RegExp('[-_]')) + .first + .toLowerCase(); + } catch (_) { + _languageTag = 'en'; + } + } + + void _startListening() async { + setState(() { + _isListening = true; + _recognizedText = ''; + _elapsedSeconds = 0; + }); + + try { + // Ensure service is initialized and has permission + final ok = await _voiceService.initialize(); + if (!ok || !await _voiceService.checkPermissions()) { + throw Exception('Microphone permission not granted'); + } + + // Start elapsed timer for UX + _elapsedTimer?.cancel(); + _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted || !_isListening) { + t.cancel(); + return; + } + setState(() => _elapsedSeconds += 1); + }); + + final stream = _voiceService.startListening(); + _intensitySub = _voiceService.intensityStream.listen((value) { + if (!mounted) return; + setState(() => _intensity = value); + }); + _textSub = stream.listen( + (text) { + // If we receive a special token with recorded audio path, transcribe it via API + if (text.startsWith('[[AUDIO_FILE_PATH]]:')) { + final filePath = text.split(':').skip(1).join(':'); + debugPrint( + 'DEBUG: VoiceInputSheet received audio file path: ' + filePath, + ); + _transcribeRecordedFile(filePath); + } else { + setState(() { + _recognizedText = text; + }); + } + }, + onDone: () { + debugPrint('DEBUG: VoiceInputSheet stream done'); + setState(() { + _isListening = false; + }); + _elapsedTimer?.cancel(); + }, + onError: (error) { + debugPrint('DEBUG: VoiceInputSheet stream error: $error'); + setState(() { + _isListening = false; + }); + _elapsedTimer?.cancel(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Voice input error: $error'), + backgroundColor: context.conduitTheme.error, + ), + ); + } + }, + ); + } catch (e) { + setState(() { + _isListening = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to start voice input: $e'), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + } + + Future _transcribeRecordedFile(String filePath) async { + try { + setState(() => _isTranscribing = true); + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('API service unavailable'); + final file = File(filePath); + final bytes = await file.readAsBytes(); + // Try to use device locale; fall back to en-US + String? language; + try { + language = WidgetsBinding.instance.platformDispatcher.locale + .toLanguageTag(); + } catch (_) { + language = 'en-US'; + } + final text = await api.transcribeAudio( + bytes.toList(), + language: language, + ); + debugPrint( + 'DEBUG: Transcription received: ' + (text.isEmpty ? '[empty]' : text), + ); + if (!mounted) return; + setState(() { + _recognizedText = text; + }); + // Stop listening state if we have a result + setState(() => _isListening = false); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Transcription failed: $e'), + backgroundColor: context.conduitTheme.error, + ), + ); + setState(() => _isListening = false); + } finally { + if (mounted) setState(() => _isTranscribing = false); + } + } + + Future _stopListening() async { + _intensitySub?.cancel(); + _intensitySub = null; + // Keep text subscription active to receive final audio path emission + await _voiceService.stopListening(); + _elapsedTimer?.cancel(); + if (mounted) { + setState(() { + _isListening = false; + }); + } + } + + void _sendText() { + if (_recognizedText.isNotEmpty) { + widget.onTextReceived(_recognizedText); + Navigator.pop(context); + } + } + + String _formatSeconds(int seconds) { + final m = (seconds ~/ 60).toString().padLeft(1, '0'); + final s = (seconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + void _cancel() { + _stopListening(); + Navigator.pop(context); + } + + @override + void dispose() { + _intensitySub?.cancel(); + _textSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.6, + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.lg), + ), + border: Border.all(color: context.conduitTheme.dividerColor, width: 1), + ), + child: Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: Spacing.sm), + width: 40, + height: 4, + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + + // Header: Title + timer + language chip + Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _isListening + ? 'Listening\u2026' + : _isTranscribing + ? 'Transcribing\u2026' + : 'Voice', + style: TextStyle( + fontSize: AppTypography.headlineMedium, + fontWeight: FontWeight.w600, + color: context.conduitTheme.textPrimary, + ), + ), + Row( + children: [ + // Language chip + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: 4, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground + .withValues(alpha: 0.4), + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: Text( + _languageTag.toUpperCase(), + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: Spacing.sm), + // Timer + AnimatedOpacity( + opacity: _isListening ? 1 : 0.6, + duration: AnimationDuration.fast, + child: Text( + _formatSeconds(_elapsedSeconds), + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + + // Microphone animation and waveform + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Microphone icon with animation (tap to toggle) + GestureDetector( + onTap: () => + _isListening ? _stopListening() : _startListening(), + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: _isListening + ? context.conduitTheme.error.withValues( + alpha: 0.2, + ) + : context.conduitTheme.surfaceBackground + .withValues(alpha: Alpha.subtle), + shape: BoxShape.circle, + border: Border.all( + color: _isListening + ? context.conduitTheme.error.withValues( + alpha: 0.5, + ) + : context.conduitTheme.dividerColor, + width: 2, + ), + ), + child: Icon( + _isListening + ? (Platform.isIOS + ? CupertinoIcons.mic_fill + : Icons.mic) + : (Platform.isIOS + ? CupertinoIcons.mic_off + : Icons.mic_off), + size: 40, + color: _isListening + ? context.conduitTheme.error + : context.conduitTheme.iconSecondary, + ), + ), + ) + .animate( + onPlay: (controller) => + _isListening ? controller.repeat() : null, + ) + .scale( + duration: const Duration(milliseconds: 1000), + begin: const Offset(1, 1), + end: const Offset(1.2, 1.2), + ) + .then() + .scale( + duration: const Duration(milliseconds: 1000), + begin: const Offset(1.2, 1.2), + end: const Offset(1, 1), + ), + + const SizedBox(height: Spacing.md), + // Simple animated bars waveform based on intensity proxy + SizedBox( + height: 32, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: Row( + key: ValueKey(_intensity), + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(12, (i) { + final normalized = ((_intensity + i) % 10) / 10.0; + final barHeight = 8 + (normalized * 24); + return Container( + width: 4, + height: barHeight, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary + .withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(2), + ), + ); + }), + ), + ), + ), + const SizedBox(height: Spacing.xl), + + // Recognized text / Transcribing state + Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.all(Spacing.md), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.2, + minHeight: 80, + ), + decoration: BoxDecoration( + color: context.conduitTheme.inputBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.inputBorder, + width: 1, + ), + ), + child: _isTranscribing + ? Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.conduitTheme.buttonPrimary, + ), + ), + const SizedBox(width: Spacing.xs), + Text( + 'Transcribing…', + style: TextStyle( + fontSize: AppTypography.bodyLarge, + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ) + : SingleChildScrollView( + child: Text( + _recognizedText.isEmpty + ? (_isListening + ? 'Speak now…' + : 'Tap Start to begin') + : _recognizedText, + style: TextStyle( + fontSize: AppTypography.bodyLarge, + color: _recognizedText.isEmpty + ? context.conduitTheme.inputPlaceholder + : context.conduitTheme.textPrimary, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + ), + + // Action buttons + Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Row( + children: [ + // Start/Stop toggle button + Expanded( + child: FilledButton.tonal( + onPressed: _isListening ? _stopListening : _startListening, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: Spacing.md), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + ), + child: Text( + _isListening ? 'Stop' : 'Start', + style: TextStyle( + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w600, + color: context.conduitTheme.textPrimary, + ), + ), + ), + ), + + const SizedBox(width: Spacing.xs), + // Cancel button + Expanded( + child: TextButton( + onPressed: _cancel, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: Spacing.md), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide( + color: context.conduitTheme.dividerColor, + width: 1, + ), + ), + ), + child: Text( + 'Cancel', + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + + const SizedBox(width: Spacing.xs), + + // Send button + Expanded( + child: FilledButton( + onPressed: _recognizedText.isNotEmpty ? _sendText : null, + style: FilledButton.styleFrom( + backgroundColor: context.conduitTheme.buttonPrimary, + foregroundColor: context.conduitTheme.buttonPrimaryText, + padding: const EdgeInsets.symmetric(vertical: Spacing.md), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + ), + child: Text( + 'Send', + style: TextStyle( + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Wrapper widget for selectable messages with visual selection indicators +class _SelectableMessageWrapper extends StatelessWidget { + final bool isSelected; + final VoidCallback onTap; + final VoidCallback? onLongPress; + final Widget child; + + const _SelectableMessageWrapper({ + required this.isSelected, + required this.onTap, + this.onLongPress, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.xs), + decoration: BoxDecoration( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: isSelected + ? Border.all( + color: context.conduitTheme.buttonPrimary.withValues( + alpha: 0.3, + ), + width: 2, + ) + : null, + ), + child: Stack( + children: [ + child, + if (isSelected) + Positioned( + top: Spacing.sm, + right: Spacing.sm, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + shape: BoxShape.circle, + boxShadow: ConduitShadows.medium, + ), + child: Icon( + Icons.check, + color: context.conduitTheme.textInverse, + size: 16, + ), + ), + ), + ], + ), + ), + ); + } +} + +// Extension on _ChatPageState for utility methods +extension on _ChatPageState { + Widget _buildChatBanner(BuildContext context, Map banner) { + final theme = Theme.of(context); + + final type = banner['type'] as String? ?? 'info'; + final content = banner['content'] as String? ?? ''; + + if (content.isEmpty) return const SizedBox.shrink(); + + Color backgroundColor; + Color textColor; + IconData icon; + + switch (type) { + case 'warning': + backgroundColor = context.conduitTheme.warning.withValues(alpha: 0.2); + textColor = context.conduitTheme.warning; + icon = Platform.isIOS + ? CupertinoIcons.exclamationmark_triangle + : Icons.warning; + break; + case 'error': + backgroundColor = theme.colorScheme.errorContainer; + textColor = theme.colorScheme.onErrorContainer; + icon = Platform.isIOS ? CupertinoIcons.xmark_circle : Icons.error; + break; + default: // info + backgroundColor = theme.colorScheme.primaryContainer; + textColor = theme.colorScheme.onPrimaryContainer; + icon = Platform.isIOS ? CupertinoIcons.info_circle : Icons.info; + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: backgroundColor, + child: Row( + children: [ + Icon(icon, color: textColor, size: 16), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + content, + style: theme.textTheme.bodySmall?.copyWith( + color: textColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/chat/views/chat_page_helpers.dart b/lib/features/chat/views/chat_page_helpers.dart new file mode 100644 index 0000000..a335c6e --- /dev/null +++ b/lib/features/chat/views/chat_page_helpers.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; + +class PressableScale extends StatefulWidget { + final Widget child; + final VoidCallback? onTap; + final BorderRadius? borderRadius; + + const PressableScale({ + super.key, + required this.child, + this.onTap, + this.borderRadius, + }); + + @override + State createState() => _PressableScaleState(); +} + +class _PressableScaleState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scale; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: AnimationDuration.fast, + lowerBound: 0.0, + upperBound: 1.0, + ); + _scale = Tween(begin: 1.0, end: 0.98).animate( + CurvedAnimation(parent: _controller, curve: AnimationCurves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails _) => _controller.forward(); + void _onTapUp(TapUpDetails _) => _controller.reverse(); + void _onTapCancel() => _controller.reverse(); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + onTap: widget.onTap, + child: ScaleTransition( + scale: _scale, + child: ClipRRect( + borderRadius: + widget.borderRadius ?? + BorderRadius.circular(AppBorderRadius.card), + child: widget.child, + ), + ), + ); + } +} + diff --git a/lib/features/chat/views/conversation_search_page.dart b/lib/features/chat/views/conversation_search_page.dart new file mode 100644 index 0000000..d7cc921 --- /dev/null +++ b/lib/features/chat/views/conversation_search_page.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:io' show Platform; +import '../../../shared/utils/platform_utils.dart'; +import '../widgets/conversation_search_widget.dart'; +import '../../../core/providers/app_providers.dart'; +import '../providers/chat_providers.dart'; +import 'chat_page.dart'; + +/// Dedicated page for conversation search functionality +class ConversationSearchPage extends ConsumerStatefulWidget { + const ConversationSearchPage({super.key}); + + @override + ConsumerState createState() => + _ConversationSearchPageState(); +} + +class _ConversationSearchPageState + extends ConsumerState { + @override + Widget build(BuildContext context) { + final conduitTheme = context.conduitTheme; + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + appBar: _buildAppBar(context, conduitTheme), + body: ConversationSearchWidget( + onResultTap: _onSearchResultTap, + showFilters: true, + ), + ); + } + + PreferredSizeWidget _buildAppBar( + BuildContext context, + ConduitThemeExtension theme, + ) { + if (Platform.isIOS) { + return CupertinoNavigationBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + border: Border(bottom: BorderSide(color: theme.cardBorder, width: 0.5)), + leading: CupertinoNavigationBarBackButton( + color: context.conduitTheme.textPrimary, + onPressed: () => Navigator.of(context).pop(), + ), + middle: Text( + 'Search Conversations', + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + return AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: Elevation.none, + title: Text( + 'Search Conversations', + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.headlineMedium, + fontWeight: FontWeight.w600, + ), + ), + leading: IconButton( + icon: Icon(Icons.arrow_back, color: context.conduitTheme.textPrimary), + onPressed: () => Navigator.of(context).pop(), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(height: 1, color: theme.cardBorder), + ), + ); + } + + void _onSearchResultTap(String conversationId, String? messageId) { + PlatformUtils.lightHaptic(); + + // Set the active conversation + final conversationsAsync = ref.read(conversationsProvider); + conversationsAsync.whenData((conversations) { + final conversation = conversations.firstWhere( + (c) => c.id == conversationId, + orElse: () => throw Exception('Conversation not found'), + ); + + // Set active conversation + ref.read(activeConversationProvider.notifier).state = conversation; + + // Navigate back to chat + Navigator.of(context).pop(); + + // If we have a specific message, navigate to it and highlight it + if (messageId != null) { + // Use a custom navigation approach with message highlighting + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => + ChatPageWithHighlight(messageIdToHighlight: messageId), + ), + ); + } + }); + } +} + +/// Chat page wrapper that highlights a specific message +class ChatPageWithHighlight extends ConsumerStatefulWidget { + final String messageIdToHighlight; + + const ChatPageWithHighlight({super.key, required this.messageIdToHighlight}); + + @override + ConsumerState createState() => + _ChatPageWithHighlightState(); +} + +class _ChatPageWithHighlightState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + + // Schedule highlighting after the widget is built + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToAndHighlightMessage(); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToAndHighlightMessage() async { + try { + final messages = ref.read(chatMessagesProvider); + final messageIndex = messages.indexWhere( + (msg) => msg.id == widget.messageIdToHighlight, + ); + + if (messageIndex >= 0 && _scrollController.hasClients) { + // Calculate the approximate position (assuming 100px per message) + final targetOffset = messageIndex * 100.0; + + // Scroll to the message + await _scrollController.animateTo( + targetOffset, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + + // Show a highlight indicator + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Found message'), + duration: const Duration(seconds: 2), + backgroundColor: context.conduitTheme.buttonPrimary, + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Message not found'), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return const ChatPage(); + } +} + +/// Search icon button for app bars +class ConversationSearchButton extends ConsumerWidget { + final VoidCallback? onPressed; + + const ConversationSearchButton({super.key, this.onPressed}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + icon: Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search, + color: context.conduitTheme.iconPrimary.withValues(alpha: 0.8), + size: IconSize.lg, + ), + onPressed: + onPressed ?? + () { + PlatformUtils.lightHaptic(); + Navigator.of(context).push( + Platform.isIOS + ? CupertinoPageRoute( + builder: (context) => const ConversationSearchPage(), + ) + : MaterialPageRoute( + builder: (context) => const ConversationSearchPage(), + ), + ); + }, + tooltip: 'Search conversations', + ); + } +} + +/// Quick search overlay that can be shown from any page +class QuickSearchOverlay extends ConsumerStatefulWidget { + final VoidCallback? onDismiss; + + const QuickSearchOverlay({super.key, this.onDismiss}); + + @override + ConsumerState createState() => _QuickSearchOverlayState(); +} + +class _QuickSearchOverlayState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + + _slideAnimation = + Tween(begin: const Offset(0, -1), end: Offset.zero).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + Future _dismiss() async { + await _animationController.reverse(); + widget.onDismiss?.call(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Stack( + children: [ + // Backdrop + GestureDetector( + onTap: _dismiss, + child: Container( + color: context.conduitTheme.surfaceBackground.withValues( + alpha: 0.7 * _fadeAnimation.value, + ), + ), + ), + + // Search panel + SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: Container( + height: MediaQuery.of(context).size.height * 0.8, + margin: const EdgeInsets.only(top: Spacing.xxxl + Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.lg), + ), + ), + child: Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: Spacing.sm), + width: 40, + height: 4, + decoration: BoxDecoration( + color: context.conduitTheme.textPrimary.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.xs, + ), + ), + ), + + // Search content + Expanded( + child: ConversationSearchWidget( + onResultTap: (conversationId, messageId) { + _onSearchResultTap(conversationId, messageId); + _dismiss(); + }, + showFilters: false, // Simplified for overlay + ), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ); + } + + void _onSearchResultTap(String conversationId, String? messageId) { + // Same logic as the search page + final conversationsAsync = ref.read(conversationsProvider); + conversationsAsync.whenData((conversations) { + final conversation = conversations.firstWhere( + (c) => c.id == conversationId, + orElse: () => throw Exception('Conversation not found'), + ); + + ref.read(activeConversationProvider.notifier).state = conversation; + + if (messageId != null) { + debugPrint( + 'Navigate to message: $messageId in conversation: $conversationId', + ); + } + }); + } +} + +/// Show quick search overlay +void showQuickSearch(BuildContext context) { + showGeneralDialog( + context: context, + barrierColor: Colors.transparent, + barrierDismissible: true, + transitionDuration: const Duration(milliseconds: 300), + pageBuilder: (context, animation, secondaryAnimation) { + return QuickSearchOverlay(onDismiss: () => Navigator.of(context).pop()); + }, + ); +} diff --git a/lib/features/chat/views/model_selector_page.dart b/lib/features/chat/views/model_selector_page.dart new file mode 100644 index 0000000..b5fbfed --- /dev/null +++ b/lib/features/chat/views/model_selector_page.dart @@ -0,0 +1,465 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:io' show Platform; +import '../../../core/models/model.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/theme/app_theme.dart'; + +class ModelSelectorPage extends ConsumerStatefulWidget { + const ModelSelectorPage({super.key}); + + @override + ConsumerState createState() => _ModelSelectorPageState(); +} + +class _ModelSelectorPageState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _onSearchChanged() { + setState(() { + _searchQuery = _searchController.text; + }); + } + + List _filterModels(List models) { + if (_searchQuery.isEmpty) { + return models; + } + + final query = _searchQuery.toLowerCase(); + return models.where((model) { + return model.name.toLowerCase().contains(query) || + (model.description?.toLowerCase().contains(query) ?? false); + }).toList(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final modelsAsync = ref.watch(modelsProvider); + final selectedModel = ref.watch(selectedModelProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Select Model'), + leading: IconButton( + icon: Icon(Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + ), + body: Column( + children: [ + // Search bar + Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + border: Border( + bottom: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.1), + width: BorderWidth.regular, + ), + ), + ), + child: _buildSearchField(), + ), + // Models list + Expanded( + child: modelsAsync.when( + data: (models) { + final filteredModels = _filterModels(models); + + if (models.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.cube_box + : Icons.view_in_ar, + size: 64, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.3, + ), + ), + const SizedBox(height: Spacing.md), + Text( + 'No models available', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + ), + ), + const SizedBox(height: Spacing.sm), + Text( + 'Please check your Open-WebUI configuration', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.5, + ), + ), + ), + ], + ), + ); + } + + if (filteredModels.isEmpty && _searchQuery.isNotEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.search + : Icons.search_off, + size: 64, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.3, + ), + ), + const SizedBox(height: Spacing.md), + Text( + 'No models found', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + ), + ), + const SizedBox(height: Spacing.sm), + Text( + 'Try searching with different keywords', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.5, + ), + ), + ), + ], + ), + ); + } + + // Group models by category if needed + final groupedModels = _groupModels(filteredModels); + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: groupedModels.length, + itemBuilder: (context, index) { + final group = groupedModels[index]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (group.title != null) ...[ + Padding( + padding: const EdgeInsets.fromLTRB( + Spacing.md, + Spacing.md, + Spacing.md, + Spacing.sm, + ), + child: Text( + group.title!, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ...group.models.map( + (model) => ModelTile( + model: model, + isSelected: selectedModel?.id == model.id, + onTap: () { + ref.read(selectedModelProvider.notifier).state = + model; + Navigator.pop(context); + }, + ), + ), + ], + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_triangle + : Icons.error_outline, + size: 48, + color: theme.colorScheme.error, + ), + const SizedBox(height: Spacing.md), + Text( + 'Failed to load models', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: Spacing.sm), + Text( + error.toString(), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.lg), + ElevatedButton.icon( + onPressed: () => ref.refresh(modelsProvider), + icon: Icon( + Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + ), + label: const Text('Retry'), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSearchField() { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + context.conduitTheme.inputBackground.withValues(alpha: 0.6), + context.conduitTheme.inputBackground.withValues(alpha: 0.3), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: context.conduitTheme.inputBorder.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + style: TextStyle( + color: context.conduitTheme.inputText, + fontSize: AppTypography.bodyMedium, + ), + decoration: InputDecoration( + hintText: 'Search models...', + hintStyle: TextStyle( + color: context.conduitTheme.inputPlaceholder.withValues(alpha: 0.8), + fontSize: AppTypography.bodyMedium, + ), + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search, + color: context.conduitTheme.iconSecondary, + size: IconSize.md, + ), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: Icon( + Platform.isIOS + ? CupertinoIcons.clear_circled_solid + : Icons.clear, + color: context.conduitTheme.iconSecondary, + size: IconSize.md, + ), + onPressed: () { + _searchController.clear(); + _searchFocusNode.unfocus(); + }, + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ); + } + + List _groupModels(List models) { + // For now, just return all models in one group + // In the future, we can group by provider, capability, etc. + return [ModelGroup(title: null, models: models)]; + } +} + +class ModelGroup { + final String? title; + final List models; + + ModelGroup({required this.title, required this.models}); +} + +class ModelTile extends StatelessWidget { + final Model model; + final bool isSelected; + final VoidCallback onTap; + + const ModelTile({ + super.key, + required this.model, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + elevation: isSelected ? 2 : 0, + color: isSelected + ? theme.colorScheme.primary.withValues(alpha: 0.1) + : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide( + color: isSelected + ? theme.colorScheme.primary + : theme.dividerColor.withValues(alpha: 0.3), + width: isSelected ? 2 : 1, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + model.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: isSelected ? FontWeight.w600 : null, + color: isSelected ? theme.colorScheme.primary : null, + ), + ), + ), + if (isSelected) + Icon( + Platform.isIOS + ? CupertinoIcons.checkmark_circle_fill + : Icons.check_circle, + color: theme.colorScheme.primary, + ), + ], + ), + if (model.description != null) ...[ + const SizedBox(height: Spacing.xs), + Text( + model.description!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: Spacing.sm), + Wrap( + spacing: 8, + children: [ + if (model.isMultimodal) + _buildCapabilityChip( + context, + icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image, + label: 'Multimodal', + color: AppTheme.info, + ), + if (model.supportsStreaming) + _buildCapabilityChip( + context, + icon: Platform.isIOS + ? CupertinoIcons.bolt + : Icons.flash_on, + label: 'Streaming', + color: AppTheme.warning, + ), + if (model.supportsRAG) + _buildCapabilityChip( + context, + icon: Platform.isIOS + ? CupertinoIcons.doc_text + : Icons.description, + label: 'RAG', + color: AppTheme.success, + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildCapabilityChip( + BuildContext context, { + required IconData icon, + required String label, + required Color color, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: Spacing.xs), + Text( + label, + style: TextStyle( + fontSize: AppTypography.labelMedium, + color: color, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/chat/widgets/conversation_components.dart b/lib/features/chat/widgets/conversation_components.dart new file mode 100644 index 0000000..d17395b --- /dev/null +++ b/lib/features/chat/widgets/conversation_components.dart @@ -0,0 +1,956 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:io' show Platform; +import 'dart:ui' as ui; + +import '../../../core/models/conversation.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/utils/ui_utils.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import '../providers/chat_providers.dart'; + +// Optimized delete conversation provider with error handling +final deleteConversationProvider = FutureProvider.family(( + ref, + conversationId, +) async { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service available'); + + await api.deleteConversation(conversationId); + ref.invalidate(conversationsProvider); +}); + +/// Optimized conversation tile with Conduit design aesthetics +class ModernConversationTile extends ConsumerStatefulWidget { + final Conversation conversation; + final bool isActive; + final Future Function() onTap; + final VoidCallback onDelete; + + const ModernConversationTile({ + super.key, + required this.conversation, + required this.isActive, + required this.onTap, + required this.onDelete, + }); + + @override + ConsumerState createState() => + _ModernConversationTileState(); +} + +class _ModernConversationTileState extends ConsumerState + with SingleTickerProviderStateMixin { + bool _isLoading = false; + late AnimationController _animationController; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 150), + vsync: this, + ); + _scaleAnimation = Tween(begin: 1.0, end: 0.95).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: Dismissible( + key: Key(widget.conversation.id), + direction: DismissDirection.horizontal, + background: _buildSwipeBackground(DismissDirection.startToEnd), + secondaryBackground: _buildSwipeBackground( + DismissDirection.endToStart, + ), + confirmDismiss: _handleDismiss, + child: _buildTileContent(), + ), + ), + ); + }, + ); + } + + Widget _buildSwipeBackground(DismissDirection direction) { + final isArchive = direction == DismissDirection.startToEnd; + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: isArchive + ? [ + AppTheme.brandPrimary.withValues(alpha: 0.1), + AppTheme.brandPrimary.withValues(alpha: 0.2), + ] + : [ + AppTheme.error.withValues(alpha: 0.1), + AppTheme.error.withValues(alpha: 0.2), + ], + ), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + ), + alignment: isArchive ? Alignment.centerLeft : Alignment.centerRight, + padding: EdgeInsets.symmetric(horizontal: Spacing.lg), + child: Container( + width: Spacing.xxl, + height: Spacing.xxl, + decoration: BoxDecoration( + color: isArchive ? AppTheme.brandPrimary : AppTheme.error, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + boxShadow: ConduitShadows.low, + ), + child: Icon( + isArchive + ? (Platform.isIOS ? CupertinoIcons.archivebox : Icons.archive) + : (Platform.isIOS ? CupertinoIcons.delete : Icons.delete), + color: AppTheme.neutral50, + size: AppTypography.headlineMedium, + ), + ), + ); + } + + Future _handleDismiss(DismissDirection direction) async { + if (direction == DismissDirection.startToEnd) { + await _handleArchive(); + } else { + widget.onDelete(); + } + return false; + } + + Widget _buildTileContent() { + return GestureDetector( + onTapDown: (_) => _animationController.forward(), + onTapUp: (_) => _animationController.reverse(), + onTapCancel: () => _animationController.reverse(), + onTap: _isLoading ? null : _handleTap, + child: Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + gradient: widget.isActive + ? LinearGradient( + colors: [ + AppTheme.brandPrimary.withValues(alpha: 0.15), + AppTheme.brandPrimary.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : LinearGradient( + colors: [ + AppTheme.neutral700.withValues(alpha: 0.6), + AppTheme.neutral700.withValues(alpha: 0.3), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: widget.isActive + ? AppTheme.brandPrimary.withValues(alpha: 0.3) + : AppTheme.neutral600.withValues(alpha: 0.2), + width: widget.isActive ? BorderWidth.medium : BorderWidth.thin, + ), + boxShadow: widget.isActive ? ConduitShadows.low : null, + ), + child: Row( + children: [ + _buildLeadingIcon(), + const SizedBox(width: Spacing.md), + Expanded(child: _buildContent()), + _buildTrailingActions(), + ], + ), + ), + ); + } + + Widget _buildLeadingIcon() { + if (_isLoading) { + return SizedBox( + width: Spacing.xl, + height: Spacing.xl, + child: CircularProgressIndicator.adaptive( + strokeWidth: BorderWidth.thick, + valueColor: AlwaysStoppedAnimation( + widget.isActive ? AppTheme.brandPrimary : AppTheme.neutral300, + ), + ), + ); + } + + return Container( + width: Spacing.xl, + height: Spacing.xl, + decoration: BoxDecoration( + gradient: widget.isActive + ? LinearGradient( + colors: [AppTheme.brandPrimary, AppTheme.brandPrimaryLight], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : LinearGradient( + colors: [ + AppTheme.neutral600.withValues(alpha: 0.8), + AppTheme.neutral500.withValues(alpha: 0.6), + ], + ), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.chat_bubble_2_fill + : Icons.chat_rounded, + color: AppTheme.neutral50, + size: Spacing.md, + ), + if (widget.conversation.pinned) + Positioned( + top: Spacing.xxs, + right: Spacing.xxs, + child: Container( + width: Spacing.sm, + height: Spacing.sm, + decoration: const BoxDecoration( + color: AppTheme.warning, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ); + } + + Widget _buildContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.conversation.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: widget.isActive ? AppTheme.neutral50 : AppTheme.neutral100, + fontWeight: FontWeight.w600, + fontSize: AppTypography.bodyLarge, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: Spacing.xs), + Row( + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.time : Icons.access_time_rounded, + size: AppTypography.labelMedium, + color: AppTheme.neutral400, + ), + const SizedBox(width: Spacing.xs), + Text( + _formatDate(widget.conversation.updatedAt), + style: const TextStyle( + color: AppTheme.neutral400, + fontSize: AppTypography.labelMedium, + fontWeight: FontWeight.w500, + ), + ), + if (widget.conversation.messages.isNotEmpty) ...[ + Container( + margin: const EdgeInsets.symmetric(horizontal: Spacing.sm), + width: Spacing.xxs, + height: Spacing.xxs, + decoration: const BoxDecoration( + color: AppTheme.neutral400, + shape: BoxShape.circle, + ), + ), + Text( + '${widget.conversation.messages.length} messages', + style: const TextStyle( + color: AppTheme.neutral400, + fontSize: AppTypography.labelMedium, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + if (widget.conversation.tags.isNotEmpty) ...[ + const SizedBox(height: Spacing.sm), + _buildTags(), + ], + ], + ); + } + + Widget _buildTags() { + return Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: widget.conversation.tags.take(3).map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs + Spacing.xxs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: AppTheme.brandPrimary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + border: Border.all( + color: AppTheme.brandPrimary.withValues(alpha: 0.2), + width: BorderWidth.thin, + ), + ), + child: Text( + tag, + style: const TextStyle( + color: AppTheme.brandPrimary, + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w600, + ), + ), + ); + }).toList(), + ); + } + + Widget _buildTrailingActions() { + return PopupMenuButton( + icon: Container( + width: Spacing.xl, + height: Spacing.xl, + decoration: BoxDecoration( + color: AppTheme.neutral700.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Icon( + Platform.isIOS ? CupertinoIcons.ellipsis : Icons.more_vert_rounded, + color: AppTheme.neutral300, + size: Spacing.md, + ), + ), + color: AppTheme.neutral800, + elevation: Elevation.high + Spacing.xs, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide( + color: AppTheme.neutral600.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + onSelected: _handleMenuAction, + itemBuilder: (context) => _buildMenuItems(), + ); + } + + List> _buildMenuItems() { + return [ + _buildMenuItem( + 'pin', + widget.conversation.pinned + ? (Platform.isIOS + ? CupertinoIcons.pin_slash + : Icons.push_pin_outlined) + : (Platform.isIOS + ? CupertinoIcons.pin_fill + : Icons.push_pin_rounded), + widget.conversation.pinned ? 'Unpin' : 'Pin', + ), + _buildMenuItem( + 'archive', + Platform.isIOS ? CupertinoIcons.archivebox : Icons.archive_rounded, + 'Archive', + ), + _buildMenuItem( + 'share', + Platform.isIOS ? CupertinoIcons.share : Icons.share_rounded, + 'Share', + ), + _buildMenuItem( + 'clone', + Platform.isIOS ? CupertinoIcons.doc_on_doc : Icons.content_copy_rounded, + 'Clone', + ), + PopupMenuItem( + enabled: false, + child: Divider(color: AppTheme.neutral600, height: BorderWidth.regular), + ), + _buildMenuItem( + 'delete', + Platform.isIOS ? CupertinoIcons.delete : Icons.delete_rounded, + 'Delete', + isDestructive: true, + ), + ]; + } + + PopupMenuItem _buildMenuItem( + String value, + IconData icon, + String label, { + bool isDestructive = false, + }) { + return PopupMenuItem( + value: value, + child: Row( + children: [ + Container( + width: Spacing.lg + Spacing.xs, + height: Spacing.lg + Spacing.xs, + decoration: BoxDecoration( + color: isDestructive + ? AppTheme.error.withValues(alpha: 0.1) + : AppTheme.neutral700.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + child: Icon( + icon, + size: Spacing.md, + color: isDestructive ? AppTheme.error : AppTheme.neutral200, + ), + ), + const SizedBox(width: Spacing.sm), + Text( + label, + style: TextStyle( + color: isDestructive ? AppTheme.error : AppTheme.neutral50, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Future _handleTap() async { + setState(() => _isLoading = true); + try { + await widget.onTap(); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _handleMenuAction(String action) async { + switch (action) { + case 'pin': + await _handlePin(); + break; + case 'archive': + await _handleArchive(); + break; + case 'share': + await _handleShare(); + break; + case 'clone': + await _handleClone(); + break; + case 'delete': + widget.onDelete(); + break; + } + } + + Future _handlePin() async { + try { + await pinConversation( + ref, + widget.conversation.id, + !widget.conversation.pinned, + ); + if (mounted) { + UiUtils.showMessage( + context, + widget.conversation.pinned + ? 'Conversation unpinned' + : 'Conversation pinned', + ); + } + } catch (e) { + if (mounted) { + UiUtils.showMessage( + context, + 'Failed to ${widget.conversation.pinned ? 'unpin' : 'pin'} conversation', + ); + } + } + } + + Future _handleArchive() async { + try { + await archiveConversation(ref, widget.conversation.id, true); + if (mounted) { + UiUtils.showMessage(context, 'Conversation archived'); + } + } catch (e) { + if (mounted) { + UiUtils.showMessage(context, 'Failed to archive conversation'); + } + } + } + + Future _handleShare() async { + try { + final shareId = await shareConversation(ref, widget.conversation.id); + if (mounted && shareId != null) { + _showShareDialog(shareId); + } + } catch (e) { + if (mounted) { + UiUtils.showMessage(context, 'Failed to share conversation'); + } + } + } + + Future _handleClone() async { + try { + await cloneConversation(ref, widget.conversation.id); + if (mounted) { + Navigator.pop(context); + UiUtils.showMessage(context, 'Conversation cloned'); + } + } catch (e) { + if (mounted) { + UiUtils.showMessage(context, 'Failed to clone conversation'); + } + } + } + + void _showShareDialog(String shareId) { + final shareUrl = + '${ref.read(apiServiceProvider)?.serverConfig.url}/s/$shareId'; + + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.neutral800, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + side: BorderSide( + color: AppTheme.neutral600.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + title: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppTheme.brandPrimary, AppTheme.brandPrimaryLight], + ), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: const Icon( + Icons.share_rounded, + color: AppTheme.neutral50, + size: Spacing.md, + ), + ), + const SizedBox(width: Spacing.sm), + const Text( + 'Share Conversation', + style: TextStyle( + color: AppTheme.neutral50, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Anyone with this link can view the conversation:', + style: TextStyle(color: AppTheme.neutral300), + ), + const SizedBox(height: Spacing.md), + Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: AppTheme.neutral700.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: AppTheme.neutral600.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: SelectableText( + shareUrl, + style: const TextStyle( + fontFamily: 'monospace', + color: AppTheme.neutral50, + fontSize: AppTypography.labelMedium, + ), + ), + ), + ], + ), + actions: [ + ConduitButton( + text: 'Close', + isSecondary: true, + onPressed: () => Navigator.pop(context), + ), + ConduitButton( + text: 'Copy Link', + onPressed: () async { + await Clipboard.setData(ClipboardData(text: shareUrl)); + if (context.mounted) { + UiUtils.showMessage(context, 'Link copied to clipboard'); + Navigator.pop(context); + } + }, + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + + // Convert to local timezone if needed + final localDate = date.toLocal(); + final localNow = now.toLocal(); + final difference = localNow.difference(localDate); + + // Handle negative differences (future dates) + if (difference.isNegative) { + return 'Just now'; + } + + if (difference.inDays == 0) { + if (difference.inHours == 0) { + if (difference.inMinutes <= 1) { + return 'Just now'; + } + return '${difference.inMinutes}m'; + } + return '${difference.inHours}h'; + } else if (difference.inDays == 1) { + return 'Yesterday'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d'; + } else if (difference.inDays < 365) { + return '${localDate.month}/${localDate.day}'; + } else { + return '${localDate.month}/${localDate.day}/${localDate.year}'; + } + } +} + +/// Optimized archived chats view with improved performance +class ModernArchivedChatsView extends ConsumerWidget { + final ScrollController scrollController; + + const ModernArchivedChatsView({super.key, required this.scrollController}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final archivedConversations = ref.watch(archivedConversationsProvider); + + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppTheme.neutral800, AppTheme.neutral900], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.only( + topLeft: ui.Radius.circular(AppBorderRadius.lg), + topRight: ui.Radius.circular(AppBorderRadius.lg), + ), + border: Border.all( + color: AppTheme.neutral600.withValues(alpha: 0.2), + width: BorderWidth.thin, + ), + ), + child: Column( + children: [ + _buildHandle(), + _buildHeader(context), + const Divider(color: AppTheme.neutral600, height: 1, thickness: 0.5), + Expanded(child: _buildContent(context, archivedConversations, ref)), + ], + ), + ); + } + + Widget _buildHandle() { + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + width: Spacing.xxl, + height: Spacing.xs, + decoration: BoxDecoration( + color: AppTheme.neutral500, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Row( + children: [ + Container( + width: Spacing.xxl, + height: Spacing.xxl, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppTheme.brandPrimary, AppTheme.brandPrimaryLight], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: const Icon( + Icons.archive_rounded, + color: AppTheme.neutral50, + size: AppTypography.headlineMedium, + ), + ), + const SizedBox(width: Spacing.md), + const Expanded( + child: Text( + 'Archived Conversations', + style: TextStyle( + color: AppTheme.neutral50, + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + ), + ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close_rounded, + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } + + Widget _buildContent( + BuildContext context, + List conversations, + WidgetRef ref, + ) { + if (conversations.isEmpty) { + return _buildEmptyState(); + } + + return ListView.builder( + controller: scrollController, + padding: const EdgeInsets.all(Spacing.md), + itemCount: conversations.length, + itemBuilder: (context, index) { + final conversation = conversations[index]; + return ModernArchivedConversationTile( + conversation: conversation, + onUnarchive: () => _handleUnarchive(ref, context, conversation.id), + ); + }, + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: Spacing.xxl + Spacing.xl, + height: Spacing.xxl + Spacing.xl, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.neutral600.withValues(alpha: 0.3), + AppTheme.neutral700.withValues(alpha: 0.1), + ], + ), + borderRadius: BorderRadius.circular(AppBorderRadius.round), + ), + child: const Icon( + Icons.archive_rounded, + size: Spacing.xxl, + color: AppTheme.neutral400, + ), + ), + const SizedBox(height: Spacing.lg), + const Text( + 'Nothing archived yet', + style: TextStyle( + color: AppTheme.neutral50, + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.sm), + const Text( + 'Conversations you archive will appear here', + style: TextStyle( + color: AppTheme.neutral400, + fontSize: AppTypography.labelLarge, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Future _handleUnarchive( + WidgetRef ref, + BuildContext context, + String conversationId, + ) async { + try { + await archiveConversation(ref, conversationId, false); + if (context.mounted) { + UiUtils.showMessage(context, 'Conversation unarchived'); + } + } catch (e) { + if (context.mounted) { + UiUtils.showMessage(context, 'Failed to unarchive conversation'); + } + } + } +} + +/// Optimized archived conversation tile +class ModernArchivedConversationTile extends StatelessWidget { + final Conversation conversation; + final VoidCallback onUnarchive; + + const ModernArchivedConversationTile({ + super.key, + required this.conversation, + required this.onUnarchive, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: Spacing.sm), + child: Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.neutral700.withValues(alpha: 0.4), + AppTheme.neutral700.withValues(alpha: 0.2), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: AppTheme.neutral600.withValues(alpha: 0.2), + width: BorderWidth.thin, + ), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppTheme.neutral600.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: const Icon( + Icons.archive_rounded, + color: AppTheme.neutral300, + size: 16, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + conversation.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppTheme.neutral50, + fontWeight: FontWeight.w600, + fontSize: AppTypography.bodyLarge, + ), + ), + const SizedBox(height: Spacing.xs), + Text( + _formatArchivedDate(conversation.updatedAt), + style: const TextStyle( + color: AppTheme.neutral400, + fontSize: AppTypography.labelMedium, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ConduitIconButton( + icon: Platform.isIOS + ? CupertinoIcons.arrow_up_bin + : Icons.unarchive_rounded, + onPressed: onUnarchive, + tooltip: 'Unarchive', + ), + ], + ), + ), + ); + } + + String _formatArchivedDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + return 'Today'; + } else if (difference.inDays == 1) { + return 'Yesterday'; + } else if (difference.inDays < 7) { + return '${difference.inDays} days ago'; + } else { + return '${date.month}/${date.day}/${date.year}'; + } + } +} diff --git a/lib/features/chat/widgets/conversation_search_widget.dart b/lib/features/chat/widgets/conversation_search_widget.dart new file mode 100644 index 0000000..5a60157 --- /dev/null +++ b/lib/features/chat/widgets/conversation_search_widget.dart @@ -0,0 +1,738 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/app_theme.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:io' show Platform; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/loading_states.dart'; +import '../../../shared/widgets/empty_states.dart'; + +import '../../../shared/utils/platform_utils.dart'; +import '../services/conversation_search_service.dart'; +import '../../../core/providers/app_providers.dart'; + +/// Advanced conversation search widget with filters and results +class ConversationSearchWidget extends ConsumerStatefulWidget { + final Function(String conversationId, String? messageId)? onResultTap; + final bool showFilters; + + const ConversationSearchWidget({ + super.key, + this.onResultTap, + this.showFilters = true, + }); + + @override + ConsumerState createState() => + _ConversationSearchWidgetState(); +} + +class _ConversationSearchWidgetState + extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + bool _isSearching = false; + bool _showFilters = false; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + void _onSearchChanged() { + final query = _searchController.text.trim(); + ref.read(searchQueryProvider.notifier).state = query; + + if (query.isNotEmpty) { + _performSearch(query); + } else { + ref.read(conversationSearchResultsProvider.notifier).state = null; + } + } + + Future _performSearch(String query) async { + if (_isSearching) return; + + setState(() { + _isSearching = true; + }); + + try { + final searchService = ref.read(conversationSearchServiceProvider); + final conversations = ref + .read(conversationsProvider) + .when( + data: (data) => data, + loading: () => [], + error: (_, _) => [], + ); + + final options = ref.read(searchOptionsProvider); + + final results = await searchService.searchConversations( + conversations: conversations.cast(), + query: query, + options: options, + ); + + ref.read(conversationSearchResultsProvider.notifier).state = results; + } catch (e) { + debugPrint('Search error: $e'); + } finally { + if (mounted) { + setState(() { + _isSearching = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final conduitTheme = context.conduitTheme; + final searchResults = ref.watch(conversationSearchResultsProvider); + + return Column( + children: [ + // Search header + Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: conduitTheme.cardBackground, + border: Border( + bottom: BorderSide( + color: conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + ), + ), + child: Column( + children: [ + // Search input + Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: conduitTheme.inputBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: _searchFocus.hasFocus + ? conduitTheme.inputBorderFocused + : conduitTheme.inputBorder, + width: BorderWidth.regular, + ), + ), + child: TextField( + controller: _searchController, + focusNode: _searchFocus, + decoration: InputDecoration( + hintText: 'Search conversations...', + hintStyle: TextStyle( + color: context.conduitTheme.inputPlaceholder, + fontSize: AppTypography.bodyLarge, + ), + prefixIcon: Icon( + Platform.isIOS + ? CupertinoIcons.search + : Icons.search, + color: context.conduitTheme.iconSecondary, + size: AppTypography.headlineMedium, + ), + suffixIcon: _isSearching + ? Padding( + padding: const EdgeInsets.all(Spacing.md), + child: ConduitLoading.inline( + size: Spacing.md, + ), + ) + : _searchController.text.isNotEmpty + ? IconButton( + icon: Icon( + Platform.isIOS + ? CupertinoIcons.clear + : Icons.clear, + color: context.conduitTheme.iconSecondary, + size: AppTypography.headlineMedium, + ), + onPressed: () { + _searchController.clear(); + _searchFocus.unfocus(); + }, + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + ), + style: TextStyle( + color: context.conduitTheme.inputText, + fontSize: AppTypography.bodyLarge, + ), + onSubmitted: (_) => _searchFocus.unfocus(), + ), + ), + ), + + // Filter toggle + if (widget.showFilters) ...[ + const SizedBox(width: Spacing.xs), + GestureDetector( + onTap: () { + PlatformUtils.lightHaptic(); + setState(() { + _showFilters = !_showFilters; + }); + }, + child: Container( + width: Spacing.xxl + Spacing.xs, + height: Spacing.xxl + Spacing.xs, + decoration: BoxDecoration( + color: _showFilters + ? AppTheme.neutral50.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular( + AppBorderRadius.md, + ), + border: Border.all( + color: _showFilters + ? AppTheme.neutral50.withValues(alpha: 0.3) + : conduitTheme.inputBorder, + width: BorderWidth.regular, + ), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.slider_horizontal_3 + : Icons.tune, + color: AppTheme.neutral50.withValues(alpha: 0.8), + size: AppTypography.headlineMedium, + ), + ), + ), + ], + ], + ), + + // Search filters + if (_showFilters && widget.showFilters) + _buildSearchFilters(conduitTheme), + ], + ), + ), + + // Search results + Expanded(child: _buildSearchResults(conduitTheme, searchResults)), + ], + ); + } + + Widget _buildSearchFilters(ConduitThemeExtension theme) { + final options = ref.watch(searchOptionsProvider); + + return Container( + margin: const EdgeInsets.only(top: Spacing.md), + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: AppTheme.neutral50.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all(color: theme.cardBorder, width: BorderWidth.regular), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Search in:', + style: theme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: AppTheme.neutral50.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: Spacing.xs), + + // Search scope toggles + Wrap( + spacing: Spacing.md, + runSpacing: Spacing.sm, + children: [ + _buildFilterToggle( + 'Titles', + options.searchTitles, + (value) => + _updateSearchOptions(options.copyWith(searchTitles: value)), + ), + _buildFilterToggle( + 'Messages', + options.searchMessages, + (value) => _updateSearchOptions( + options.copyWith(searchMessages: value), + ), + ), + _buildFilterToggle( + 'Tags', + options.searchTags, + (value) => + _updateSearchOptions(options.copyWith(searchTags: value)), + ), + ], + ), + + const SizedBox(height: Spacing.md), + + Text( + 'Message type:', + style: theme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: AppTheme.neutral50.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: Spacing.xs), + + // Role filter + Wrap( + spacing: Spacing.md, + runSpacing: Spacing.sm, + children: [ + _buildFilterChip( + 'All', + options.roleFilter == null, + () => _updateSearchOptions(options.copyWith(roleFilter: null)), + ), + _buildFilterChip( + 'My messages', + options.roleFilter == 'user', + () => + _updateSearchOptions(options.copyWith(roleFilter: 'user')), + ), + _buildFilterChip( + 'AI messages', + options.roleFilter == 'assistant', + () => _updateSearchOptions( + options.copyWith(roleFilter: 'assistant'), + ), + ), + ], + ), + ], + ), + ).animate().slideY( + begin: -0.5, + end: 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + Widget _buildFilterToggle( + String label, + bool value, + Function(bool) onChanged, + ) { + return GestureDetector( + onTap: () { + PlatformUtils.selectionHaptic(); + onChanged(!value); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: AppTypography.headlineMedium, + height: AppTypography.headlineMedium, + decoration: BoxDecoration( + color: value ? AppTheme.brandPrimary : Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + border: Border.all( + color: value + ? AppTheme.brandPrimary + : AppTheme.neutral50.withValues(alpha: 0.3), + width: BorderWidth.regular, + ), + ), + child: value + ? const Icon( + Icons.check, + color: AppTheme.neutral50, + size: AppTypography.labelLarge, + ) + : null, + ), + const SizedBox(width: Spacing.sm), + Text( + label, + style: TextStyle( + color: AppTheme.neutral50.withValues(alpha: 0.8), + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildFilterChip(String label, bool isActive, VoidCallback onTap) { + return GestureDetector( + onTap: () { + PlatformUtils.selectionHaptic(); + onTap(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xs + Spacing.xxs, + ), + decoration: BoxDecoration( + color: isActive + ? AppTheme.brandPrimary.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: isActive + ? AppTheme.brandPrimary + : AppTheme.neutral50.withValues(alpha: 0.3), + width: BorderWidth.regular, + ), + ), + child: Text( + label, + style: TextStyle( + color: isActive + ? AppTheme.brandPrimary + : AppTheme.neutral50.withValues(alpha: 0.8), + fontSize: AppTypography.labelMedium, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + void _updateSearchOptions(ConversationSearchOptions newOptions) { + ref.read(searchOptionsProvider.notifier).state = newOptions; + + // Re-search with new options if we have a query + final query = _searchController.text.trim(); + if (query.isNotEmpty) { + _performSearch(query); + } + } + + Widget _buildSearchResults( + ConduitThemeExtension theme, + ConversationSearchResults? results, + ) { + if (_searchController.text.trim().isEmpty) { + return _buildSearchPrompt(theme); + } + + if (results == null) { + return Center(child: ConduitLoading.primary()); + } + + if (results.isEmpty) { + return SearchEmptyState( + query: results.query, + onClearSearch: () { + _searchController.clear(); + _searchFocus.unfocus(); + }, + ); + } + + return Column( + children: [ + // Results header + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: AppTheme.neutral50.withValues(alpha: 0.05), + border: Border( + bottom: BorderSide( + color: theme.cardBorder, + width: BorderWidth.regular, + ), + ), + ), + child: Row( + children: [ + Text( + '${results.length} of ${results.totalMatches} results', + style: theme.bodySmall?.copyWith( + color: AppTheme.neutral50.withValues(alpha: 0.7), + ), + ), + const Spacer(), + Text( + '${results.searchDuration.inMilliseconds}ms', + style: theme.bodySmall?.copyWith( + color: AppTheme.neutral50.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + + // Results list + Expanded( + child: ListView.builder( + itemCount: results.length, + itemBuilder: (context, index) { + final match = results.results[index]; + return _buildSearchResultItem(theme, match, index); + }, + ), + ), + ], + ); + } + + Widget _buildSearchPrompt(ConduitThemeExtension theme) { + return ConduitEmptyState( + title: 'Search your conversations', + subtitle: 'Find messages, titles, and tags across all your conversations', + icon: Platform.isIOS ? CupertinoIcons.search : Icons.search, + ); + } + + Widget _buildSearchResultItem( + ConduitThemeExtension theme, + ConversationSearchMatch match, + int index, + ) { + return GestureDetector( + onTap: () { + PlatformUtils.lightHaptic(); + widget.onResultTap?.call(match.conversationId, match.messageId); + }, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: theme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.cardBorder, + width: BorderWidth.regular, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with conversation title and match type + Row( + children: [ + Expanded( + child: Text( + match.conversationTitle, + style: theme.headingSmall?.copyWith( + fontSize: AppTypography.bodyLarge, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: Spacing.sm), + _buildMatchTypeBadge(match.matchType), + ], + ), + + const SizedBox(height: Spacing.sm), + + // Snippet with highlighted text + _buildHighlightedSnippet(theme, match.highlightedSnippet), + + const SizedBox(height: Spacing.sm), + + // Footer with metadata + Row( + children: [ + if (match.messageRole != null) ...[ + _buildRoleBadge(match.messageRole!), + const SizedBox(width: Spacing.sm), + ], + Text( + _formatTimestamp(match.timestamp), + style: theme.caption, + ), + const Spacer(), + Text( + '${match.relevanceScore.round()}% match', + style: theme.caption?.copyWith( + color: AppTheme.brandPrimary, + ), + ), + ], + ), + ], + ), + ), + ) + .animate(delay: Duration(milliseconds: index * 50)) + .fadeIn(duration: const Duration(milliseconds: 200)) + .slideX(begin: 0.3, end: 0); + } + + Widget _buildMatchTypeBadge(SearchMatchType type) { + Color color; + String label; + + switch (type) { + case SearchMatchType.title: + color = AppTheme.info; + label = 'Title'; + break; + case SearchMatchType.message: + color = AppTheme.success; + label = 'Message'; + break; + case SearchMatchType.tag: + color = AppTheme.warning; + label = 'Tag'; + break; + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildRoleBadge(String role) { + Color color; + String label; + + switch (role) { + case 'user': + color = AppTheme.brandPrimary; + label = 'You'; + break; + case 'assistant': + color = AppTheme.success; + label = 'AI'; + break; + case 'system': + color = AppTheme.warning; + label = 'System'; + break; + default: + color = AppTheme.neutral400; + label = role; + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs + Spacing.xxs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + Widget _buildHighlightedSnippet( + ConduitThemeExtension theme, + String highlightedText, + ) { + // Simple implementation - in a real app you'd want proper HTML parsing + final parts = highlightedText.split(''); + final spans = []; + + for (int i = 0; i < parts.length; i++) { + final part = parts[i]; + if (i == 0) { + spans.add(TextSpan(text: part)); + } else { + final markParts = part.split(''); + if (markParts.length >= 2) { + // Highlighted part + spans.add( + TextSpan( + text: markParts[0], + style: TextStyle( + backgroundColor: AppTheme.brandPrimary.withValues(alpha: 0.3), + color: AppTheme.neutral50, + fontWeight: FontWeight.w600, + ), + ), + ); + // Rest of the text + spans.add(TextSpan(text: markParts.sublist(1).join(''))); + } else { + spans.add(TextSpan(text: part)); + } + } + } + + return RichText( + text: TextSpan( + style: theme.bodyMedium?.copyWith( + color: AppTheme.neutral50.withValues(alpha: 0.8), + height: 1.4, + ), + children: spans, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ); + } + + String _formatTimestamp(DateTime timestamp) { + final now = DateTime.now(); + final diff = now.difference(timestamp); + + if (diff.inDays > 7) { + return '${timestamp.day}/${timestamp.month}/${timestamp.year}'; + } else if (diff.inDays > 0) { + return '${diff.inDays}d ago'; + } else if (diff.inHours > 0) { + return '${diff.inHours}h ago'; + } else if (diff.inMinutes > 0) { + return '${diff.inMinutes}m ago'; + } else { + return 'Just now'; + } + } +} diff --git a/lib/features/chat/widgets/documentation_message_widget.dart b/lib/features/chat/widgets/documentation_message_widget.dart new file mode 100644 index 0000000..edefa7d --- /dev/null +++ b/lib/features/chat/widgets/documentation_message_widget.dart @@ -0,0 +1,688 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:async'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'dart:io' show Platform; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../core/utils/reasoning_parser.dart'; + +class DocumentationMessageWidget extends ConsumerStatefulWidget { + final dynamic message; + final bool isUser; + final bool isStreaming; + final String? modelName; + final VoidCallback? onCopy; + final VoidCallback? onEdit; + final VoidCallback? onRegenerate; + final VoidCallback? onLike; + final VoidCallback? onDislike; + + const DocumentationMessageWidget({ + super.key, + required this.message, + required this.isUser, + this.isStreaming = false, + this.modelName, + this.onCopy, + this.onEdit, + this.onRegenerate, + this.onLike, + this.onDislike, + }); + + @override + ConsumerState createState() => + _DocumentationMessageWidgetState(); +} + +class _DocumentationMessageWidgetState + extends ConsumerState + with TickerProviderStateMixin { + bool _showActions = false; + bool _showReasoning = false; + late AnimationController _fadeController; + late AnimationController _slideController; + ReasoningContent? _reasoningContent; + String _renderedContent = ''; + Timer? _throttleTimer; + String? _pendingContent; + + @override + void initState() { + super.initState(); + _renderedContent = widget.message.content ?? ''; + _fadeController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + _slideController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + // Parse reasoning content if present + _updateReasoningContent(); + } + + @override + void didUpdateWidget(DocumentationMessageWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + // Re-parse reasoning content when message content changes + if (oldWidget.message.content != widget.message.content) { + // Throttle markdown re-rendering for smoother streaming + _scheduleRenderUpdate(widget.message.content ?? ''); + _updateReasoningContent(); + } + } + + void _updateReasoningContent() { + if (!widget.isUser && widget.message.content != null) { + final newReasoningContent = ReasoningParser.parseReasoningContent( + widget.message.content!, + ); + if (newReasoningContent != _reasoningContent) { + setState(() { + _reasoningContent = newReasoningContent; + }); + } + } + } + + void _scheduleRenderUpdate(String rawContent) { + final safe = _safeForStreaming(rawContent); + if (_throttleTimer != null && _throttleTimer!.isActive) { + _pendingContent = safe; + return; + } + if (mounted) { + setState(() => _renderedContent = safe); + } else { + _renderedContent = safe; + } + _throttleTimer = Timer(const Duration(milliseconds: 80), () { + if (!mounted) return; + if (_pendingContent != null) { + setState(() { + _renderedContent = _pendingContent!; + _pendingContent = null; + }); + } + }); + } + + String _safeForStreaming(String content) { + if (content.isEmpty) return content; + // Auto-close an unbalanced triple backtick fence during streaming so markdown stays valid + final fenceCount = '```'.allMatches(content).length; + if (fenceCount.isOdd) { + return '$content\n```'; + } + return content; + } + + @override + void dispose() { + _fadeController.dispose(); + _slideController.dispose(); + super.dispose(); + } + + void _toggleActions() { + setState(() { + _showActions = !_showActions; + }); + + if (_showActions) { + _fadeController.forward(); + _slideController.forward(); + } else { + _fadeController.reverse(); + _slideController.reverse(); + } + } + + @override + Widget build(BuildContext context) { + if (widget.isUser) { + return _buildUserMessage(); + } else { + return _buildDocumentationMessage(); + } + } + + Widget _buildUserMessage() { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 16, left: 50, right: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: GestureDetector( + onLongPress: () => _toggleActions(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: context.conduitTheme.chatBubbleUser, + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: context.conduitTheme.chatBubbleUserBorder, + width: BorderWidth.regular, + ), + ), + child: Text( + widget.message.content, + style: TextStyle( + color: context.conduitTheme.chatBubbleUserText, + fontSize: AppTypography.bodyLarge, + height: 1.5, + letterSpacing: 0.1, + ), + ), + ), + ), + ), + ], + ), + ) + .animate() + .fadeIn(duration: const Duration(milliseconds: 400)) + .slideX( + begin: 0.2, + end: 0, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + ); + } + + Widget _buildDocumentationMessage() { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 24, left: 12, right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Simplified AI Name and Avatar + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + borderRadius: BorderRadius.circular( + AppBorderRadius.small, + ), + ), + child: Icon( + Icons.auto_awesome, + color: context.conduitTheme.buttonPrimaryText, + size: 12, + ), + ), + const SizedBox(width: Spacing.xs), + Text( + widget.modelName ?? 'Assistant', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + ), + ], + ), + ), + + // Reasoning Section (if present) + if (_reasoningContent != null) ...[ + InkWell( + onTap: () => setState(() => _showReasoning = !_showReasoning), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _showReasoning + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 16, + color: context.conduitTheme.textSecondary, + ), + const SizedBox(width: Spacing.xs), + Icon( + Icons.psychology_outlined, + size: 14, + color: context.conduitTheme.buttonPrimary, + ), + const SizedBox(width: Spacing.xs), + Text( + _reasoningContent!.summary.isNotEmpty + ? _reasoningContent!.summary + : 'Thought for ${_reasoningContent!.formattedDuration}', + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + + // Expandable reasoning content + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Container( + margin: const EdgeInsets.only(top: Spacing.sm), + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: SelectableText( + _reasoningContent!.cleanedReasoning, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: context.conduitTheme.textSecondary, + fontFamily: 'monospace', + height: 1.4, + ), + ), + ), + crossFadeState: _showReasoning + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + + const SizedBox(height: Spacing.md), + ], + + // Documentation-style content without heavy bubble; premium markdown + GestureDetector( + onLongPress: () => _toggleActions(), + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.isStreaming && + (widget.message.content.trim().isEmpty || + widget.message.content == '[TYPING_INDICATOR]')) + _buildTypingIndicator() + else if (widget.isStreaming && + widget.message.content.isNotEmpty && + widget.message.content != '[TYPING_INDICATOR]') + // While streaming, render markdown with throttling and safety fixes + _buildEnhancedMarkdownContent(_renderedContent) + else + // After streaming finishes (or static content), render full markdown + _buildEnhancedMarkdownContent( + _reasoningContent?.mainContent ?? + widget.message.content, + ), + + // Action buttons - inline and minimal + if (_showActions) ...[ + const SizedBox(height: Spacing.md), + _buildActionButtons(), + ], + ], + ), + ), + ), + ], + ), + ) + .animate() + .fadeIn(duration: const Duration(milliseconds: 300)) + .slideY( + begin: 0.1, + end: 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + + Widget _buildEnhancedMarkdownContent(String content) { + if (content.trim().isEmpty) { + return const SizedBox.shrink(); + } + + final codeFence = RegExp( + r"```([\w\-\+\.#]*)\n([\s\S]*?)```", + multiLine: true, + ); + final widgets = []; + int lastIndex = 0; + for (final match in codeFence.allMatches(content)) { + if (match.start > lastIndex) { + final textSegment = content.substring(lastIndex, match.start); + widgets.add( + GptMarkdown( + textSegment, + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + height: 1.6, + letterSpacing: 0.1, + ), + ), + ); + } + + final language = match.group(1)?.trim().isEmpty == true + ? null + : match.group(1)!.trim(); + final code = match.group(2) ?? ''; + widgets.add(_buildCodeBlock(code, language)); + lastIndex = match.end; + } + + if (lastIndex < content.length) { + final tail = content.substring(lastIndex); + widgets.add( + GptMarkdown( + tail, + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + height: 1.6, + letterSpacing: 0.1, + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets + .map( + (w) => Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: w, + ), + ) + .toList(), + ); + } + + Widget _buildCodeBlock(String code, String? language) { + return Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.7), + width: BorderWidth.thin, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.chevron_left_slash_chevron_right + : Icons.code, + size: 14, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.xs), + Expanded( + child: Text( + language?.toUpperCase() ?? 'CODE', + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ), + GestureDetector( + onTap: () => _copyToClipboard(code), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues( + alpha: 0.2, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.doc_on_clipboard + : Icons.copy, + size: 14, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.xs), + Text( + 'Copy', + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(AppBorderRadius.md), + ), + ), + child: SelectableText( + code.trimRight(), + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontFamily: AppTypography.monospaceFontFamily, + fontSize: AppTypography.bodySmall, + height: 1.5, + ), + ), + ), + ], + ), + ); + } + + void _copyToClipboard(String text) { + Clipboard.setData(ClipboardData(text: text)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Code copied'), + backgroundColor: context.conduitTheme.buttonPrimary, + ), + ); + } + } + + // Removed lightweight streaming text; we now stream markdown with throttling + + Widget _buildTypingIndicator() { + return Consumer( + builder: (context, ref, child) { + const statusText = 'Thinking about your question...'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + statusText, + style: TextStyle( + color: context.conduitTheme.textSecondary.withValues( + alpha: 0.7, + ), + fontSize: AppTypography.bodyMedium, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: Spacing.xs), + Row( + children: [ + _buildTypingDot(0), + const SizedBox(width: Spacing.xs), + _buildTypingDot(200), + const SizedBox(width: Spacing.xs), + _buildTypingDot(400), + ], + ), + ], + ); + }, + ); + } + + Widget _buildTypingDot(int delay) { + return Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: context.conduitTheme.textSecondary.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ) + .animate(onPlay: (controller) => controller.repeat()) + .scale( + duration: const Duration(milliseconds: 1000), + begin: const Offset(1, 1), + end: const Offset(1.3, 1.3), + ) + .then(delay: Duration(milliseconds: delay)) + .scale( + duration: const Duration(milliseconds: 1000), + begin: const Offset(1.3, 1.3), + end: const Offset(1, 1), + ); + } + + Widget _buildActionButtons() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.doc_on_clipboard + : Icons.content_copy, + label: 'Copy', + onTap: widget.onCopy, + ), + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.hand_thumbsup + : Icons.thumb_up_outlined, + label: 'Like', + onTap: widget.onLike, + ), + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.hand_thumbsdown + : Icons.thumb_down_outlined, + label: 'Dislike', + onTap: widget.onDislike, + ), + _buildActionButton( + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + label: 'Regenerate', + onTap: widget.onRegenerate, + ), + ], + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: context.conduitTheme.textPrimary.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: context.conduitTheme.textPrimary.withValues(alpha: 0.08), + width: BorderWidth.regular, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: IconSize.sm, + color: context.conduitTheme.textPrimary.withValues(alpha: 0.8), + ), + const SizedBox(width: Spacing.xs), + Text( + label, + style: TextStyle( + fontSize: AppTypography.labelMedium, + color: context.conduitTheme.textPrimary.withValues(alpha: 0.8), + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/chat/widgets/file_attachment_widget.dart b/lib/features/chat/widgets/file_attachment_widget.dart new file mode 100644 index 0000000..999e2d8 --- /dev/null +++ b/lib/features/chat/widgets/file_attachment_widget.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:io' show Platform; +import '../services/file_attachment_service.dart'; +import '../../../shared/widgets/loading_states.dart'; + +class FileAttachmentWidget extends ConsumerWidget { + const FileAttachmentWidget({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final attachedFiles = ref.watch(attachedFilesProvider); + + if (attachedFiles.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.sm, Spacing.md, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Attachments', + style: TextStyle( + color: context.conduitTheme.textSecondary.withValues(alpha: 0.7), + fontSize: AppTypography.labelMedium, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: Spacing.sm), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: attachedFiles + .map( + (fileState) => Padding( + padding: const EdgeInsets.only(right: Spacing.sm), + child: _FileAttachmentCard(fileState: fileState), + ), + ) + .toList(), + ), + ), + ], + ), + ).animate().fadeIn(duration: const Duration(milliseconds: 300)); + } +} + +class _FileAttachmentCard extends ConsumerWidget { + final FileUploadState fileState; + + const _FileAttachmentCard({required this.fileState}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + width: 160, + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: _getBorderColor(fileState.status, context), + width: BorderWidth.regular, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + fileState.fileIcon, + style: const TextStyle(fontSize: AppTypography.headlineLarge), + ), + const Spacer(), + _buildStatusIcon(context), + ], + ), + const SizedBox(height: Spacing.sm), + Text( + fileState.fileName, + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: Spacing.xs), + Text( + fileState.formattedSize, + style: TextStyle( + color: context.conduitTheme.textSecondary.withValues(alpha: 0.6), + fontSize: AppTypography.labelMedium, + ), + ), + if (fileState.status == FileUploadStatus.uploading) ...[ + const SizedBox(height: Spacing.sm), + _buildProgressBar(context), + ], + if (fileState.error != null) ...[ + const SizedBox(height: Spacing.xs), + Text( + 'Failed to upload', + style: TextStyle( + color: context.conduitTheme.error, + fontSize: AppTypography.labelMedium, + ), + ), + ], + ], + ), + ); + } + + Widget _buildStatusIcon(BuildContext context) { + switch (fileState.status) { + case FileUploadStatus.pending: + return Icon( + Platform.isIOS ? CupertinoIcons.clock : Icons.schedule, + size: IconSize.sm, + color: context.conduitTheme.iconDisabled, + ); + case FileUploadStatus.uploading: + return ConduitLoading.inline( + size: IconSize.sm, + color: context.conduitTheme.iconSecondary, + ); + case FileUploadStatus.completed: + return Icon( + Platform.isIOS + ? CupertinoIcons.checkmark_circle_fill + : Icons.check_circle, + size: IconSize.sm, + color: context.conduitTheme.success, + ); + case FileUploadStatus.failed: + return GestureDetector( + onTap: () { + // Retry upload + }, + child: Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle_fill + : Icons.error, + size: IconSize.sm, + color: context.conduitTheme.error, + ), + ); + } + } + + Widget _buildProgressBar(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + child: LinearProgressIndicator( + value: fileState.progress, + backgroundColor: context.conduitTheme.textPrimary.withValues( + alpha: 0.1, + ), + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.buttonPrimary, + ), + minHeight: 4, + ), + ); + } + + Color _getBorderColor(FileUploadStatus status, BuildContext context) { + switch (status) { + case FileUploadStatus.pending: + return context.conduitTheme.textPrimary.withValues(alpha: 0.2); + case FileUploadStatus.uploading: + return context.conduitTheme.buttonPrimary.withValues(alpha: 0.5); + case FileUploadStatus.completed: + return context.conduitTheme.success.withValues(alpha: 0.3); + case FileUploadStatus.failed: + return context.conduitTheme.error.withValues(alpha: 0.3); + } + } +} + +// Attachment preview for messages +class MessageAttachmentPreview extends StatelessWidget { + final List fileIds; + + const MessageAttachmentPreview({super.key, required this.fileIds}); + + @override + Widget build(BuildContext context) { + if (fileIds.isEmpty) return const SizedBox.shrink(); + + return Container( + margin: const EdgeInsets.only(top: Spacing.sm), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: fileIds + .map( + (fileId) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: context.conduitTheme.textPrimary.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all( + color: context.conduitTheme.textPrimary.withValues( + alpha: 0.2, + ), + width: BorderWidth.regular, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '📎', + style: TextStyle(fontSize: AppTypography.bodyLarge), + ), + const SizedBox(width: Spacing.xs), + Text( + 'Attachment', + style: TextStyle( + color: context.conduitTheme.textPrimary.withValues( + alpha: 0.8, + ), + fontSize: AppTypography.labelLarge, + ), + ), + ], + ), + ), + ) + .toList(), + ), + ); + } +} diff --git a/lib/features/chat/widgets/file_viewer_dialog.dart b/lib/features/chat/widgets/file_viewer_dialog.dart new file mode 100644 index 0000000..d56d2f9 --- /dev/null +++ b/lib/features/chat/widgets/file_viewer_dialog.dart @@ -0,0 +1,242 @@ +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/file_info.dart'; +import '../../../core/providers/app_providers.dart'; + +class FileViewerDialog extends ConsumerWidget { + final FileInfo fileInfo; + + const FileViewerDialog({super.key, required this.fileInfo}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Use themed tokens via extension + final fileContent = ref.watch(fileContentProvider(fileInfo.id)); + + return Dialog.fullscreen( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: 0, + title: Text( + fileInfo.originalFilename, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + iconTheme: IconThemeData(color: context.conduitTheme.iconPrimary), + leading: IconButton( + icon: Icon(Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + actions: [ + IconButton( + icon: Icon(Platform.isIOS ? CupertinoIcons.info : Icons.info), + onPressed: () => _showFileInfo(context), + ), + ], + ), + body: fileContent.when( + data: (content) => _buildContentView(context, content), + loading: () => Center( + child: CircularProgressIndicator( + color: context.conduitTheme.buttonPrimary, + ), + ), + error: (error, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error, size: 64, color: context.conduitTheme.error), + const SizedBox(height: Spacing.md), + Text( + 'Failed to load file', + style: TextStyle( + color: context.conduitTheme.error, + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + error.toString(), + style: TextStyle(color: context.conduitTheme.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.md), + ElevatedButton( + onPressed: () => + ref.invalidate(fileContentProvider(fileInfo.id)), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildContentView(BuildContext context, String content) { + final theme = context.conduitTheme; + final isTextFile = _isTextFile(fileInfo.mimeType); + + if (!isTextFile) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _getFileIcon(fileInfo.mimeType), + size: 64, + color: theme.buttonPrimary, + ), + const SizedBox(height: Spacing.md), + Text( + fileInfo.originalFilename, + style: TextStyle( + color: theme.textPrimary, + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.sm), + Text( + 'File type: ${fileInfo.mimeType}', + style: TextStyle(color: theme.textSecondary), + ), + Text( + 'Size: ${_formatFileSize(fileInfo.size)}', + style: TextStyle(color: theme.textSecondary), + ), + const SizedBox(height: Spacing.md), + Text( + 'Preview not available for this file type', + style: TextStyle(color: theme.textTertiary), + ), + ], + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(Spacing.md), + child: SelectableText( + content, + style: TextStyle( + color: theme.textPrimary, + fontFamily: 'monospace', + fontSize: AppTypography.labelLarge, + ), + ), + ); + } + + void _showFileInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: context.conduitTheme.surfaceBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.dialog), + ), + title: Text( + 'File Information', + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow(context, 'Name', fileInfo.originalFilename), + _buildInfoRow(context, 'Size', _formatFileSize(fileInfo.size)), + _buildInfoRow(context, 'Type', fileInfo.mimeType), + _buildInfoRow(context, 'Created', _formatDate(fileInfo.createdAt)), + _buildInfoRow(context, 'Modified', _formatDate(fileInfo.updatedAt)), + if (fileInfo.hash != null) + _buildInfoRow(context, 'Hash', fileInfo.hash!), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Close', + style: TextStyle(color: context.conduitTheme.buttonPrimary), + ), + ), + ], + ), + ); + } + + Widget _buildInfoRow(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: Spacing.xxxl + Spacing.md, + child: Text( + '$label:', + style: TextStyle( + fontWeight: FontWeight.w600, + color: context.conduitTheme.textSecondary, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + ), + ], + ), + ); + } + + bool _isTextFile(String mimeType) { + return mimeType.startsWith('text/') || + mimeType == 'application/json' || + mimeType == 'application/xml' || + mimeType == 'application/javascript' || + mimeType.contains('yaml') || + mimeType.contains('markdown'); + } + + IconData _getFileIcon(String mimeType) { + if (mimeType.startsWith('image/')) { + return Platform.isIOS ? CupertinoIcons.photo : Icons.image; + } else if (mimeType.startsWith('video/')) { + return Platform.isIOS ? CupertinoIcons.video_camera : Icons.video_file; + } else if (mimeType.startsWith('audio/')) { + return Platform.isIOS ? CupertinoIcons.music_note : Icons.audio_file; + } else if (mimeType.contains('pdf')) { + return Platform.isIOS ? CupertinoIcons.doc : Icons.picture_as_pdf; + } else if (mimeType.startsWith('text/') || mimeType.contains('json')) { + return Platform.isIOS ? CupertinoIcons.doc_text : Icons.description; + } else { + return Platform.isIOS ? CupertinoIcons.doc : Icons.insert_drive_file; + } + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + String _formatDate(DateTime date) { + return '${date.day}/${date.month}/${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/features/chat/widgets/folder_management_dialog.dart b/lib/features/chat/widgets/folder_management_dialog.dart new file mode 100644 index 0000000..add4352 --- /dev/null +++ b/lib/features/chat/widgets/folder_management_dialog.dart @@ -0,0 +1,708 @@ +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import '../../../shared/theme/app_theme.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import '../../../shared/utils/ui_utils.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/folder.dart'; +import '../../../core/models/conversation.dart'; +import '../../../core/providers/app_providers.dart'; + +class FolderManagementDialog extends ConsumerStatefulWidget { + final Conversation? conversation; + + const FolderManagementDialog({super.key, this.conversation}); + + @override + ConsumerState createState() => + _FolderManagementDialogState(); +} + +class _FolderManagementDialogState + extends ConsumerState { + final _nameController = TextEditingController(); + bool _isCreating = false; + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final folders = ref.watch(foldersProvider); + + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + width: 400, + constraints: const BoxConstraints(maxHeight: 600), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + border: Border.all( + color: context.conduitTheme.cardBorder.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Container( + padding: const EdgeInsets.all(Spacing.lg), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + context.conduitTheme.buttonPrimary, + context.conduitTheme.buttonPrimary.withValues(alpha: 0.8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.xl), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.conduitTheme.textInverse.withValues( + alpha: 0.2, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.folder + : Icons.folder_rounded, + color: context.conduitTheme.textInverse, + size: IconSize.md, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Text( + widget.conversation != null + ? 'Move to Folder' + : 'Manage Folders', + style: TextStyle( + color: context.conduitTheme.textInverse, + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + ), + ), + ), + ConduitIconButton( + icon: Platform.isIOS + ? CupertinoIcons.xmark + : Icons.close_rounded, + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // Create new folder section + Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: context.conduitTheme.inputBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.inputBorder, + width: BorderWidth.thin, + ), + ), + child: TextField( + controller: _nameController, + style: TextStyle( + color: context.conduitTheme.inputText, + fontSize: AppTypography.bodyLarge, + ), + decoration: InputDecoration( + hintText: 'New folder name', + hintStyle: TextStyle( + color: context.conduitTheme.inputPlaceholder + .withValues(alpha: 0.5), + fontSize: AppTypography.bodyLarge, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + prefixIcon: Icon( + Platform.isIOS + ? CupertinoIcons.folder_badge_plus + : Icons.create_new_folder_rounded, + color: context.conduitTheme.iconSecondary, + size: IconSize.md, + ), + ), + onSubmitted: (_) => _createFolder(), + ), + ), + ), + const SizedBox(width: Spacing.md), + ConduitButton( + text: 'Create', + onPressed: _isCreating ? null : _createFolder, + isLoading: _isCreating, + width: 80, + ), + ], + ), + ), + + // Divider + Container( + height: 0.5, + margin: const EdgeInsets.symmetric(horizontal: Spacing.lg), + color: context.conduitTheme.dividerColor.withValues(alpha: 0.3), + ), + + // Folders list + Expanded( + child: folders.when( + data: (folderList) => folderList.isEmpty + ? _buildEmptyState() + : ListView.builder( + padding: const EdgeInsets.symmetric( + vertical: Spacing.sm, + ), + itemCount: folderList.length, + itemBuilder: (context, index) { + final folder = folderList[index]; + return _buildFolderTile(folder); + }, + ), + loading: () => _buildLoadingState(), + error: (error, _) => _buildErrorState(error), + ), + ), + + // Bottom actions + if (widget.conversation != null) ...[ + Container( + height: 0.5, + margin: const EdgeInsets.symmetric(horizontal: Spacing.lg), + color: context.conduitTheme.dividerColor.withValues(alpha: 0.3), + ), + Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Row( + children: [ + Expanded( + child: ConduitButton( + text: 'Remove from Folder', + onPressed: () => _moveToFolder(null), + isSecondary: true, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: ConduitButton( + text: 'Cancel', + onPressed: () => Navigator.pop(context), + isSecondary: true, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.xl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground.withValues( + alpha: 0.6, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.round), + ), + child: Icon( + Platform.isIOS ? CupertinoIcons.folder : Icons.folder_outlined, + size: 40, + color: context.conduitTheme.iconSecondary, + ), + ), + const SizedBox(height: Spacing.lg), + Text( + 'No folders yet', + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + 'Create a folder to organize\nyour conversations', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.labelLarge, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildLoadingState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator.adaptive( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.buttonPrimary, + ), + ), + SizedBox(height: Spacing.lg), + Text( + 'Loading folders...', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildErrorState(Object error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.xl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: context.conduitTheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppBorderRadius.round), + ), + child: Icon( + Icons.error_outline_rounded, + size: 40, + color: context.conduitTheme.error, + ), + ), + const SizedBox(height: Spacing.lg), + Text( + 'Failed to load folders', + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + error.toString(), + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.labelLarge, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildFolderTile(Folder folder) { + final isSelected = widget.conversation?.folderId == folder.id; + + return Container( + margin: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + vertical: Spacing.xs, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.conversation != null + ? () => _moveToFolder(folder.id) + : null, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1) + : context.conduitTheme.cardBackground.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.3) + : context.conduitTheme.cardBorder.withValues(alpha: 0.2), + width: BorderWidth.thin, + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues( + alpha: 0.2, + ) + : context.conduitTheme.cardBorder.withValues( + alpha: 0.6, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.folder_fill + : Icons.folder_rounded, + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.iconSecondary, + size: IconSize.md, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + folder.name, + style: TextStyle( + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + const SizedBox(height: Spacing.xxs), + Text( + '${folder.conversationIds.length} conversations', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.labelMedium, + ), + ), + ], + ), + ), + if (widget.conversation != null && isSelected) + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + borderRadius: BorderRadius.circular( + AppBorderRadius.round, + ), + ), + child: Icon( + Icons.check_rounded, + color: context.conduitTheme.textInverse, + size: 16, + ), + ) + else if (widget.conversation == null) + PopupMenuButton( + icon: Icon( + Icons.more_vert_rounded, + color: context.conduitTheme.iconSecondary, + size: IconSize.md, + ), + color: context.conduitTheme.cardBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + side: BorderSide( + color: context.conduitTheme.cardBorder.withValues( + alpha: 0.3, + ), + width: BorderWidth.thin, + ), + ), + onSelected: (value) { + switch (value) { + case 'rename': + _renameFolder(folder); + break; + case 'delete': + _deleteFolder(folder); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'rename', + child: Row( + children: [ + Icon( + Icons.edit_rounded, + size: 18, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.sm), + Text( + 'Rename', + style: TextStyle( + color: context.conduitTheme.textPrimary, + ), + ), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon( + Icons.delete_rounded, + size: 18, + color: context.conduitTheme.error, + ), + const SizedBox(width: Spacing.sm), + Text( + 'Delete', + style: TextStyle( + color: context.conduitTheme.error, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Future _createFolder() async { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + + setState(() => _isCreating = true); + + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service available'); + + await api.createFolder(name: name); + ref.invalidate(foldersProvider); + _nameController.clear(); + + if (mounted) { + UiUtils.showMessage(context, 'Folder "$name" created'); + } + } catch (e) { + if (mounted) { + UiUtils.showMessage(context, 'Error creating folder: $e'); + } + } finally { + if (mounted) { + setState(() => _isCreating = false); + } + } + } + + Future _moveToFolder(String? folderId) async { + if (widget.conversation == null) return; + + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service available'); + + await api.moveConversationToFolder(widget.conversation!.id, folderId); + ref.invalidate(conversationsProvider); + ref.invalidate(foldersProvider); + + if (mounted) { + Navigator.pop(context); + UiUtils.showMessage( + context, + folderId != null + ? 'Conversation moved to folder' + : 'Conversation removed from folder', + ); + } + } catch (e) { + if (mounted) { + UiUtils.showMessage(context, 'Error moving conversation: $e'); + } + } + } + + void _renameFolder(Folder folder) async { + final controller = TextEditingController(text: folder.name); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.neutral700, + title: Text( + 'Rename Folder', + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + content: TextField( + controller: controller, + style: TextStyle(color: context.conduitTheme.inputText), + decoration: InputDecoration( + hintText: 'Folder name', + hintStyle: TextStyle( + color: context.conduitTheme.inputPlaceholder.withValues( + alpha: 0.5, + ), + ), + border: OutlineInputBorder( + borderSide: BorderSide( + color: context.conduitTheme.inputBorder.withValues(alpha: 0.2), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: context.conduitTheme.inputBorder.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: context.conduitTheme.buttonPrimary), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Cancel', + style: TextStyle( + color: context.conduitTheme.textPrimary.withValues(alpha: 0.7), + ), + ), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + style: FilledButton.styleFrom( + backgroundColor: context.conduitTheme.buttonPrimary, + ), + child: const Text('Rename'), + ), + ], + ), + ); + + if (result != null && result.isNotEmpty && result != folder.name) { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + await api.updateFolder(folder.id, name: result); + ref.invalidate(foldersProvider); + + if (mounted) { + UiUtils.showMessage(context, 'Folder renamed to "$result"'); + } + } + } catch (e) { + if (mounted) { + UiUtils.showMessage(context, 'Failed to rename folder: $e'); + } + } + } + + controller.dispose(); + } + + void _deleteFolder(Folder folder) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: context.conduitTheme.cardBackground, + title: Text( + 'Delete Folder', + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + content: Text( + 'Are you sure you want to delete "${folder.name}"?\n\nThis action cannot be undone. Conversations in this folder will be moved to the main folder.', + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text( + 'Cancel', + style: TextStyle( + color: context.conduitTheme.textPrimary.withValues(alpha: 0.7), + ), + ), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom( + backgroundColor: context.conduitTheme.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + await api.deleteFolder(folder.id); + ref.invalidate(foldersProvider); + ref.invalidate(conversationsProvider); + + if (mounted) { + UiUtils.showMessage(context, 'Folder "${folder.name}" deleted'); + } + } + } catch (e) { + if (mounted) { + UiUtils.showMessage(context, 'Failed to delete folder: $e'); + } + } + } + } +} diff --git a/lib/features/chat/widgets/message_batch_widget.dart b/lib/features/chat/widgets/message_batch_widget.dart new file mode 100644 index 0000000..a5631d9 --- /dev/null +++ b/lib/features/chat/widgets/message_batch_widget.dart @@ -0,0 +1,1056 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/app_theme.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:io' show Platform; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/utils/platform_utils.dart'; + +import '../services/message_batch_service.dart'; +import '../../../core/models/chat_message.dart'; +import '../../../core/providers/app_providers.dart'; +import '../providers/chat_providers.dart'; +import '../../../shared/widgets/themed_dialogs.dart'; + +/// Batch operations toolbar that appears when messages are selected +class MessageBatchToolbar extends ConsumerWidget { + final List selectedMessages; + final VoidCallback? onCancel; + + const MessageBatchToolbar({ + super.key, + required this.selectedMessages, + this.onCancel, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conduitTheme = context.conduitTheme; + final selectedCount = selectedMessages.length; + + return Container( + height: 80, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + color: conduitTheme.cardBackground, + border: Border( + top: BorderSide(color: conduitTheme.cardBorder, width: 1), + ), + boxShadow: ConduitShadows.medium, + ), + child: SafeArea( + child: Row( + children: [ + // Selected count + Expanded( + child: Text( + '$selectedCount message${selectedCount == 1 ? '' : 's'} selected', + style: conduitTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + + // Action buttons + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.doc_on_clipboard + : Icons.copy, + label: 'Copy', + onPressed: () => _showCopyOptions(context, ref), + ), + + const SizedBox(width: Spacing.sm), + + _buildActionButton( + icon: Platform.isIOS ? CupertinoIcons.share : Icons.share, + label: 'Export', + onPressed: () => _showExportOptions(context, ref), + ), + + const SizedBox(width: Spacing.sm), + + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.ellipsis_circle + : Icons.more_vert, + label: 'More', + onPressed: () => _showMoreOptions(context, ref), + ), + + const SizedBox(width: Spacing.sm), + + // Cancel button + GestureDetector( + onTap: () { + PlatformUtils.lightHaptic(); + onCancel?.call(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + color: AppTheme.neutral50.withValues(alpha: Alpha.subtle), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Text( + 'Cancel', + style: TextStyle( + color: AppTheme.neutral50.withValues(alpha: 0.8), + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ).animate().slideY( + begin: 1, + end: 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + required VoidCallback onPressed, + }) { + return GestureDetector( + onTap: () { + PlatformUtils.lightHaptic(); + onPressed(); + }, + child: Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: AppTheme.neutral50.withValues(alpha: Alpha.subtle), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: AppTheme.neutral50.withValues(alpha: 0.8), + size: IconSize.md, + ), + const SizedBox(height: Spacing.xxs), + Text( + label, + style: TextStyle( + color: AppTheme.neutral50.withValues(alpha: 0.8), + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + void _showCopyOptions(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => CopyOptionsSheet(messages: selectedMessages), + ); + } + + void _showExportOptions(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => ExportOptionsSheet(messages: selectedMessages), + ); + } + + void _showMoreOptions(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => MoreOptionsSheet(messages: selectedMessages), + ); + } +} + +/// Copy options bottom sheet +class CopyOptionsSheet extends ConsumerWidget { + final List messages; + + const CopyOptionsSheet({super.key, required this.messages}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conduitTheme = context.conduitTheme; + + return Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.lg), + ), + boxShadow: ConduitShadows.modal, + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: Spacing.sm), + width: 40, + height: 4, + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + + const SizedBox(height: Spacing.lg - Spacing.xs), + + // Title + Text('Copy Messages', style: conduitTheme.headingMedium), + + const SizedBox(height: Spacing.lg - Spacing.xs), + + // Copy options + _buildCopyOption( + context, + ref, + icon: Icons.text_fields, + title: 'Plain Text', + subtitle: 'Copy as plain text', + format: CopyFormat.plain, + ), + + _buildCopyOption( + context, + ref, + icon: Icons.code, + title: 'Markdown', + subtitle: 'Copy with formatting', + format: CopyFormat.markdown, + ), + + _buildCopyOption( + context, + ref, + icon: Icons.data_object, + title: 'JSON', + subtitle: 'Copy as structured data', + format: CopyFormat.json, + ), + + const SizedBox(height: Spacing.lg - Spacing.xs), + ], + ), + ), + ); + } + + Widget _buildCopyOption( + BuildContext context, + WidgetRef ref, { + required IconData icon, + required String title, + required String subtitle, + required CopyFormat format, + }) { + return ListTile( + leading: Icon(icon, color: context.conduitTheme.iconSecondary), + title: Text( + title, + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + subtitle, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + onTap: () async { + Navigator.pop(context); + await _copyMessages(context, ref, format); + }, + ); + } + + Future _copyMessages( + BuildContext context, + WidgetRef ref, + CopyFormat format, + ) async { + try { + final batchService = ref.read(messageBatchServiceProvider); + final result = await batchService.copyMessages( + messages: messages, + format: format, + ); + + if (result.success) { + final content = result.data?['content'] as String?; + if (content != null) { + await Clipboard.setData(ClipboardData(text: content)); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${messages.length} messages copied to clipboard', + ), + backgroundColor: AppTheme.success, + ), + ); + } + } + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to copy messages: ${result.error}'), + backgroundColor: AppTheme.error, + ), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error copying messages: $e'), + backgroundColor: AppTheme.error, + ), + ); + } + } + } +} + +/// Export options bottom sheet +class ExportOptionsSheet extends ConsumerWidget { + final List messages; + + const ExportOptionsSheet({super.key, required this.messages}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conduitTheme = context.conduitTheme; + + return Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.lg), + ), + boxShadow: ConduitShadows.modal, + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: Spacing.sm), + width: 40, + height: 4, + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + + const SizedBox(height: Spacing.lg - Spacing.xs), + + // Title + Text('Export Messages', style: conduitTheme.headingMedium), + + const SizedBox(height: Spacing.lg - Spacing.xs), + + // Export options + _buildExportOption( + context, + ref, + icon: Icons.text_fields, + title: 'Text File', + subtitle: 'Export as plain text (.txt)', + format: ExportFormat.text, + ), + + _buildExportOption( + context, + ref, + icon: Icons.code, + title: 'Markdown', + subtitle: 'Export with formatting (.md)', + format: ExportFormat.markdown, + ), + + _buildExportOption( + context, + ref, + icon: Icons.data_object, + title: 'JSON', + subtitle: 'Export as structured data (.json)', + format: ExportFormat.json, + ), + + _buildExportOption( + context, + ref, + icon: Icons.table_chart, + title: 'CSV', + subtitle: 'Export as spreadsheet (.csv)', + format: ExportFormat.csv, + ), + + const SizedBox(height: Spacing.lg - Spacing.xs), + ], + ), + ), + ); + } + + Widget _buildExportOption( + BuildContext context, + WidgetRef ref, { + required IconData icon, + required String title, + required String subtitle, + required ExportFormat format, + }) { + return ListTile( + leading: Icon(icon, color: AppTheme.neutral50.withValues(alpha: 0.8)), + title: Text( + title, + style: const TextStyle( + color: AppTheme.neutral50, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + subtitle, + style: TextStyle( + color: AppTheme.neutral50.withValues(alpha: Alpha.strong), + ), + ), + onTap: () { + Navigator.pop(context); + _showExportDialog(context, ref, format); + }, + ); + } + + void _showExportDialog( + BuildContext context, + WidgetRef ref, + ExportFormat format, + ) { + showDialog( + context: context, + builder: (context) => ExportDialog(messages: messages, format: format), + ); + } +} + +/// More options bottom sheet for additional batch operations +class MoreOptionsSheet extends ConsumerWidget { + final List messages; + + const MoreOptionsSheet({super.key, required this.messages}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conduitTheme = context.conduitTheme; + + return Container( + decoration: BoxDecoration( + color: conduitTheme.cardBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.lg), + ), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: Spacing.sm), + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.neutral50.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + + const SizedBox(height: Spacing.lg - Spacing.xs), + + // Title + Text('More Actions', style: conduitTheme.headingMedium), + + const SizedBox(height: Spacing.lg - Spacing.xs), + + // More options + ListTile( + leading: Icon( + Icons.label_outline, + color: context.conduitTheme.iconSecondary, + ), + title: Text( + 'Add Tags', + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + 'Tag selected messages', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + onTap: () { + Navigator.pop(context); + _showTagDialog(context, ref); + }, + ), + + ListTile( + leading: Icon( + Icons.archive_outlined, + color: context.conduitTheme.iconSecondary, + ), + title: Text( + 'Archive', + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + 'Archive selected messages', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + onTap: () { + Navigator.pop(context); + _archiveMessages(context, ref); + }, + ), + + ListTile( + leading: Icon( + Icons.delete_outline, + color: context.conduitTheme.error, + ), + title: Text( + 'Delete', + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.error, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + 'Delete selected messages', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + onTap: () { + Navigator.pop(context); + _showDeleteConfirmation(context, ref); + }, + ), + + const SizedBox(height: Spacing.lg - Spacing.xs), + ], + ), + ), + ); + } + + void _showTagDialog(BuildContext context, WidgetRef ref) async { + final activeConversation = ref.read(activeConversationProvider); + if (activeConversation == null) return; + + final controller = TextEditingController(); + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + backgroundColor: context.conduitTheme.surfaceBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.dialog), + ), + title: Text( + 'Manage Tags', + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Add new tag input + TextField( + controller: controller, + style: TextStyle(color: context.conduitTheme.textPrimary), + decoration: InputDecoration( + hintText: 'Add a tag', + hintStyle: TextStyle( + color: context.conduitTheme.inputPlaceholder, + ), + border: OutlineInputBorder( + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: context.conduitTheme.buttonPrimary, + ), + ), + suffixIcon: IconButton( + icon: Icon( + Icons.add, + color: context.conduitTheme.buttonPrimary, + ), + onPressed: () async { + final tag = controller.text.trim(); + if (tag.isNotEmpty) { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + await api.addTagToConversation( + activeConversation.id, + tag, + ); + controller.clear(); + setState(() {}); // Refresh the dialog + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Tag "$tag" added')), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to add tag: $e'), + backgroundColor: AppTheme.error, + ), + ); + } + } + } + }, + ), + ), + ), + + const SizedBox(height: Spacing.md), + + // Current tags + FutureBuilder>( + future: _loadConversationTags(ref, activeConversation.id), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Center( + child: CircularProgressIndicator( + color: context.conduitTheme.buttonPrimary, + ), + ); + } + + final tags = snapshot.data ?? []; + + if (tags.isEmpty) { + return Text( + 'No tags yet', + style: TextStyle( + color: context.conduitTheme.textSecondary, + ), + ); + } + + return Wrap( + spacing: 8, + runSpacing: 8, + children: tags + .map( + (tag) => Chip( + label: Text( + tag, + style: TextStyle( + color: context.conduitTheme.textPrimary, + ), + ), + backgroundColor: context + .conduitTheme + .buttonPrimary + .withValues(alpha: 0.2), + deleteIcon: Icon( + Icons.close, + color: context.conduitTheme.iconSecondary, + size: IconSize.sm, + ), + onDeleted: () async { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + await api.removeTagFromConversation( + activeConversation.id, + tag, + ); + setState(() {}); // Refresh the dialog + + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text('Tag "$tag" removed'), + ), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to remove tag: $e', + ), + backgroundColor: + context.conduitTheme.error, + ), + ); + } + } + }, + ), + ) + .toList(), + ); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + controller.dispose(); + Navigator.pop(context); + }, + child: Text( + 'Done', + style: TextStyle( + color: AppTheme.neutral50.withValues(alpha: Alpha.strong), + ), + ), + ), + ], + ), + ), + ); + } + + Future> _loadConversationTags( + WidgetRef ref, + String conversationId, + ) async { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + return await api.getConversationTags(conversationId); + } + } catch (e) { + // Return empty list on error + } + return []; + } + + void _archiveMessages(BuildContext context, WidgetRef ref) async { + final activeConversation = ref.read(activeConversationProvider); + if (activeConversation == null) return; + + final confirmed = await ThemedDialogs.confirm( + context, + title: 'Archive Conversation', + message: + 'Archive this conversation? You can find it in the archived conversations section.', + confirmText: 'Archive', + ); + + if (confirmed == true) { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + await api.archiveConversation(activeConversation.id, true); + ref.invalidate(conversationsProvider); + ref.invalidate(archivedConversationsProvider); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Conversation archived')), + ); + + // Navigate back or clear current conversation + Navigator.of(context).popUntil((route) => route.isFirst); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to archive conversation: $e'), + backgroundColor: AppTheme.error, + ), + ); + } + } + } + } + + void _showDeleteConfirmation(BuildContext context, WidgetRef ref) { + ThemedDialogs.confirm( + context, + title: 'Delete Messages', + message: + 'Are you sure you want to delete ${messages.length} message${messages.length == 1 ? '' : 's'}? This action cannot be undone.', + confirmText: 'Delete', + isDestructive: true, + ).then((confirmed) { + if (confirmed == true) { + _deleteMessages(context, ref); + } + }); + } + + void _deleteMessages(BuildContext context, WidgetRef ref) async { + final activeConversation = ref.read(activeConversationProvider); + if (activeConversation == null) return; + + final confirmed = await ThemedDialogs.confirm( + context, + title: 'Delete Conversation', + message: + 'Are you sure you want to delete this conversation?\n\nThis action cannot be undone.', + confirmText: 'Delete', + isDestructive: true, + ); + + if (confirmed == true) { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + await api.deleteConversation(activeConversation.id); + ref.invalidate(conversationsProvider); + ref.invalidate(archivedConversationsProvider); + + // Clear the current conversation + ref.read(activeConversationProvider.notifier).state = null; + ref.read(chatMessagesProvider.notifier).clearMessages(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Conversation deleted')), + ); + + // Navigate back to conversation list + Navigator.of(context).popUntil((route) => route.isFirst); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to delete conversation: $e'), + backgroundColor: AppTheme.error, + ), + ); + } + } + } + } +} + +/// Export dialog with options +class ExportDialog extends ConsumerStatefulWidget { + final List messages; + final ExportFormat format; + + const ExportDialog({super.key, required this.messages, required this.format}); + + @override + ConsumerState createState() => _ExportDialogState(); +} + +class _ExportDialogState extends ConsumerState { + bool _includeTimestamps = true; + bool _includeMetadata = false; + bool _includeAttachments = true; + bool _isExporting = false; + + @override + Widget build(BuildContext context) { + final conduitTheme = context.conduitTheme; + + return AlertDialog( + backgroundColor: AppTheme.neutral700, + title: Text('Export Options', style: conduitTheme.headingMedium), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Export ${widget.messages.length} messages as ${widget.format.name.toUpperCase()}', + style: conduitTheme.bodyMedium?.copyWith( + color: AppTheme.neutral50.withValues(alpha: 0.8), + ), + ), + + const SizedBox(height: Spacing.lg - Spacing.xs), + + // Export options + CheckboxListTile( + title: const Text( + 'Include timestamps', + style: TextStyle(color: AppTheme.neutral50), + ), + value: _includeTimestamps, + onChanged: (value) => + setState(() => _includeTimestamps = value ?? true), + activeColor: AppTheme.brandPrimary, + ), + + CheckboxListTile( + title: const Text( + 'Include metadata', + style: TextStyle(color: AppTheme.neutral50), + ), + value: _includeMetadata, + onChanged: (value) => + setState(() => _includeMetadata = value ?? false), + activeColor: AppTheme.brandPrimary, + ), + + CheckboxListTile( + title: const Text( + 'Include attachments', + style: TextStyle(color: AppTheme.neutral50), + ), + value: _includeAttachments, + onChanged: (value) => + setState(() => _includeAttachments = value ?? true), + activeColor: AppTheme.brandPrimary, + ), + ], + ), + actions: [ + TextButton( + onPressed: _isExporting ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: _isExporting ? null : _performExport, + child: _isExporting + ? const SizedBox( + width: Spacing.md, + height: Spacing.md, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Export'), + ), + ], + ); + } + + Future _performExport() async { + setState(() => _isExporting = true); + + try { + final batchService = ref.read(messageBatchServiceProvider); + final options = ExportOptions( + includeTimestamps: _includeTimestamps, + includeMetadata: _includeMetadata, + includeAttachments: _includeAttachments, + ); + + final result = await batchService.exportMessages( + messages: widget.messages, + format: widget.format, + options: options, + ); + + if (result.success && mounted) { + Navigator.pop(context); + + // In a real app, you would save the file or share it + // For now, we'll copy to clipboard + final content = result.data?['content'] as String?; + if (content != null) { + await Clipboard.setData(ClipboardData(text: content)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Export copied to clipboard (${widget.format.name.toUpperCase()})', + ), + backgroundColor: AppTheme.success, + ), + ); + } + } + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Export failed: ${result.error}'), + backgroundColor: AppTheme.error, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Export error: $e'), + backgroundColor: AppTheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isExporting = false); + } + } + } +} diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart new file mode 100644 index 0000000..44265a9 --- /dev/null +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -0,0 +1,792 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; + +import 'dart:io' show Platform; +import 'dart:async'; +import '../providers/chat_providers.dart'; + +import '../../../shared/utils/platform_utils.dart'; + +class ModernChatInput extends ConsumerStatefulWidget { + final Function(String) onSendMessage; + final bool enabled; + final Function()? onVoiceInput; + final Function()? onFileAttachment; + final Function()? onImageAttachment; + final Function()? onCameraCapture; + + const ModernChatInput({ + super.key, + required this.onSendMessage, + this.enabled = true, + this.onVoiceInput, + this.onFileAttachment, + this.onImageAttachment, + this.onCameraCapture, + }); + + @override + ConsumerState createState() => _ModernChatInputState(); +} + +class _ModernChatInputState extends ConsumerState + with TickerProviderStateMixin { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final bool _isRecording = false; + bool _isExpanded = true; // Start expanded for better UX + // TODO: Implement voice input functionality + // final String _voiceInputText = ''; + bool _hasText = false; // track locally without rebuilding on each keystroke + StreamSubscription? _voiceStreamSubscription; + late AnimationController _expandController; + late AnimationController _pulseController; + Timer? _blurCollapseTimer; + bool _hasAutoFocusedOnce = false; + + @override + void initState() { + super.initState(); + _expandController = AnimationController( + duration: + AnimationDuration.fast, // Faster animation for better responsiveness + vsync: this, + value: 1.0, // Start expanded + ); + _pulseController = AnimationController( + duration: AnimationDuration.slow, + vsync: this, + ); + + // Listen for text changes and update only when emptiness flips + _controller.addListener(() { + final has = _controller.text.trim().isNotEmpty; + if (has != _hasText) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _hasText = has); + // Intelligent expansion: expand when user starts typing + if (has && !_isExpanded) { + _setExpanded(true); + } + }); + } + }); + + // Intelligent expand/collapse around focus changes + _focusNode.addListener(() { + // Cancel any pending blur-driven collapse + _blurCollapseTimer?.cancel(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final hasFocus = _focusNode.hasFocus; + if (hasFocus) { + if (!_isExpanded) _setExpanded(true); + } else { + // Defer collapse slightly to avoid IME show/hide race conditions + _blurCollapseTimer = Timer(const Duration(milliseconds: 160), () { + if (!mounted) return; + if (_focusNode.hasFocus) return; // focus came back + // Collapse only when keyboard is fully hidden to avoid flicker + final keyboardVisible = + MediaQuery.of(context).viewInsets.bottom > 0; + if (keyboardVisible) return; + final has = _controller.text.trim().isNotEmpty; + if (!has && _isExpanded) { + _setExpanded(false); + } + }); + } + }); + }); + + // Let autofocus handle the focus - no manual intervention + // The TextField's autofocus: true should handle focus and keyboard automatically + // Additionally, request focus after first frame to ensure reliability across platforms + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!_hasAutoFocusedOnce && widget.enabled) { + _ensureFocusedIfEnabled(); + _hasAutoFocusedOnce = true; + } + }); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + _expandController.dispose(); + _pulseController.dispose(); + _blurCollapseTimer?.cancel(); + _voiceStreamSubscription?.cancel(); + super.dispose(); + } + + void _ensureFocusedIfEnabled() { + if (!widget.enabled) return; + if (!_focusNode.hasFocus) { + FocusScope.of(context).requestFocus(_focusNode); + } + } + + @override + void didUpdateWidget(covariant ModernChatInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.enabled && !oldWidget.enabled && !_hasAutoFocusedOnce) { + // Became enabled (e.g., after selecting a model) → focus the input + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _ensureFocusedIfEnabled(); + _hasAutoFocusedOnce = true; + }); + } + if (!widget.enabled && oldWidget.enabled) { + // Became disabled → collapse and hide keyboard + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_isExpanded) _setExpanded(false); + if (_focusNode.hasFocus) { + _focusNode.unfocus(); + } + }); + } + } + + void _sendMessage() { + final text = _controller.text.trim(); + if (text.isEmpty || !widget.enabled) return; + + PlatformUtils.lightHaptic(); + widget.onSendMessage(text); + _controller.clear(); + // Keep input expanded and focused for better UX - don't dismiss keyboard + // KeyboardUtils.dismissKeyboard(context); + // _setExpanded(false); + } + + void _setExpanded(bool expanded) { + if (_isExpanded == expanded) return; + setState(() { + _isExpanded = expanded; + }); + if (expanded) { + _expandController.forward(); + } else { + _expandController.reverse(); + } + } + + @override + Widget build(BuildContext context) { + // Check if assistant is currently generating by checking last assistant message streaming + final messages = ref.watch(chatMessagesProvider); + final isGenerating = + messages.isNotEmpty && + messages.last.role == 'assistant' && + messages.last.isStreaming; + final stopGeneration = ref.read(stopGenerationProvider); + + return Container( + // Transparent wrapper so rounded corners are visible against page background + color: Colors.transparent, + padding: EdgeInsets.only( + left: 0, + right: 0, + top: Spacing.xs.toDouble(), + bottom: 0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Web search status indicator + _buildWebSearchStatusIndicator(), + + // Main input area with unified 2-row design + Container( + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + decoration: BoxDecoration( + color: context.conduitTheme.inputBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.xl), + bottom: Radius.circular(0), + ), + border: Border( + top: BorderSide( + color: context.conduitTheme.inputBorder, + width: BorderWidth.regular, + ), + left: BorderSide( + color: context.conduitTheme.inputBorder, + width: BorderWidth.regular, + ), + right: BorderSide( + color: context.conduitTheme.inputBorder, + width: BorderWidth.regular, + ), + // Removed bottom border to eliminate divider + ), + boxShadow: ConduitShadows.input, + ), + width: double.infinity, + child: ConstrainedBox( + constraints: BoxConstraints( + // cap the input area to 40% of screen height to avoid bottom overflow + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + child: AnimatedSize( + duration: + AnimationDuration.fast, // Faster for better responsiveness + curve: Curves.fastOutSlowIn, // More efficient curve + alignment: Alignment.topCenter, + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Collapsed/Expanded top row: text input with left/right buttons in collapsed + Padding( + padding: const EdgeInsets.all(Spacing.inputPadding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (!_isExpanded) ...[ + _buildRoundButton( + icon: Icons.add, + onTap: widget.enabled + ? _showAttachmentOptions + : null, + tooltip: 'Add attachment', + ), + const SizedBox(width: Spacing.sm), + ], + // Text input expands to fill + Expanded( + child: Semantics( + textField: true, + label: 'Message input', + hint: 'Type your message', + child: TextField( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + autofocus: false, + maxLines: _isExpanded ? null : 1, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + showCursor: true, + cursorColor: context.conduitTheme.inputText, + style: AppTypography.chatMessageStyle + .copyWith( + color: context.conduitTheme.inputText, + ), + decoration: InputDecoration( + hintText: 'Message...', + hintStyle: TextStyle( + color: + context.conduitTheme.inputPlaceholder, + fontSize: AppTypography.bodyLarge, + fontWeight: _isRecording + ? FontWeight.w500 + : FontWeight.w400, + fontStyle: _isRecording + ? FontStyle.italic + : FontStyle.normal, + ), + // Ensure the text field background matches its parent container + // and does not use the global InputDecorationTheme fill + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + alignLabelWithHint: true, + ), + // Removed onChanged setState to reduce rebuilds + onSubmitted: (_) => _sendMessage(), + onTap: () { + if (!widget.enabled) return; + if (!_isExpanded) { + _setExpanded(true); + WidgetsBinding.instance + .addPostFrameCallback((_) { + if (!mounted) return; + _ensureFocusedIfEnabled(); + }); + } else { + _ensureFocusedIfEnabled(); + } + }, + ), + ), + ), + if (!_isExpanded) ...[ + const SizedBox(width: Spacing.sm), + // Primary action button (Send/Stop) when collapsed + _buildPrimaryButton( + _hasText, + isGenerating, + stopGeneration, + ), + ], + ], + ), + ), + + // Expanded bottom row with additional options + if (_isExpanded) ...[ + Container( + padding: const EdgeInsets.only( + left: Spacing.inputPadding, + right: Spacing.inputPadding, + bottom: Spacing.inputPadding, + ), + child: FadeTransition( + opacity: _expandController, + child: Row( + children: [ + _buildRoundButton( + icon: Icons.add, + onTap: widget.enabled + ? _showAttachmentOptions + : null, + tooltip: 'Add attachment', + ), + const SizedBox(width: Spacing.sm), + Flexible( + child: Center(child: _buildResearchToggle()), + ), + const SizedBox(width: Spacing.md), + // Microphone button: call provided callback for premium voice UI + _buildRoundButton( + icon: Platform.isIOS + ? CupertinoIcons.mic_fill + : Icons.mic, + onTap: widget.enabled + ? widget.onVoiceInput + : null, + tooltip: 'Voice input', + isActive: _isRecording, + ), + const SizedBox(width: Spacing.sm), + // Primary action button (Send/Stop) when expanded + _buildPrimaryButton( + _hasText, + isGenerating, + stopGeneration, + ), + ], + ), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildPrimaryButton( + bool hasText, + bool isGenerating, + void Function() stopGeneration, + ) { + // Spec: 48px touch target, circular radius, md icon size + const double buttonSize = TouchTarget.comfortable; // 48.0 + const double radius = AppBorderRadius.round; // big to ensure circle + + final enabled = !isGenerating && hasText && widget.enabled; + + // Generating -> STOP variant + if (isGenerating) { + return Tooltip( + message: 'Stop generating', + child: GestureDetector( + onTap: stopGeneration, + child: Container( + width: buttonSize, + height: buttonSize, + decoration: BoxDecoration( + color: context.conduitTheme.error.withValues( + alpha: Alpha.buttonPressed, + ), + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: context.conduitTheme.error, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.button, + ), + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: buttonSize - 18, + height: buttonSize - 18, + child: CircularProgressIndicator( + strokeWidth: BorderWidth.medium, + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.error, + ), + ), + ), + Icon( + Platform.isIOS ? CupertinoIcons.stop_fill : Icons.stop, + size: IconSize.medium, + color: context.conduitTheme.error, + ), + ], + ), + ), + ), + ); + } + + // Default SEND variant + return Tooltip( + message: enabled ? 'Send message' : 'Send', + child: GestureDetector( + onTap: enabled ? _sendMessage : null, + child: Opacity( + opacity: enabled ? Alpha.primary : Alpha.disabled, + child: IgnorePointer( + ignoring: !enabled, + child: Container( + width: buttonSize, + height: buttonSize, + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: enabled + ? context.conduitTheme.cardBorder + : context.conduitTheme.cardBorder.withValues( + alpha: Alpha.medium, + ), + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.button, + ), + child: Icon( + Platform.isIOS ? CupertinoIcons.arrow_up : Icons.arrow_upward, + size: IconSize.medium, + color: enabled + ? context.conduitTheme.textPrimary + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.disabled, + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildRoundButton({ + required IconData icon, + VoidCallback? onTap, + String? tooltip, + bool isActive = false, + bool showBackground = true, + }) { + return Tooltip( + message: tooltip ?? '', + child: GestureDetector( + onTap: onTap, + child: Container( + width: TouchTarget.comfortable, + height: TouchTarget.comfortable, + decoration: BoxDecoration( + color: isActive + ? context.conduitTheme.textPrimary.withValues( + alpha: Alpha.buttonHover, + ) + : showBackground + ? context.conduitTheme.cardBackground + : Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + border: Border.all( + color: isActive + ? context.conduitTheme.textPrimary.withValues( + alpha: Alpha.buttonHover + Alpha.subtle, + ) + : showBackground + ? context.conduitTheme.cardBorder + : Colors.transparent, + width: BorderWidth.regular, + ), + boxShadow: (isActive || showBackground) + ? ConduitShadows.button + : null, + ), + child: Icon( + icon, + size: IconSize.medium, + color: widget.enabled + ? (isActive + ? context.conduitTheme.textPrimary + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.strong, + )) + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.disabled, + ), + ), + ), + ), + ); + } + + Widget _buildResearchToggle() { + final webSearchEnabled = ref.watch( + webSearchEnabledProvider.select((enabled) => enabled), + ); + + return GestureDetector( + onTap: widget.enabled + ? () { + ref.read(webSearchEnabledProvider.notifier).state = + !webSearchEnabled; + } + : null, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + color: webSearchEnabled + ? context.conduitTheme.textPrimary.withValues( + alpha: Alpha.buttonHover, + ) + : context.conduitTheme.surfaceBackground.withValues( + alpha: Alpha.subtle, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + border: Border.all( + color: webSearchEnabled + ? context.conduitTheme.textPrimary.withValues( + alpha: Alpha.buttonHover + Alpha.subtle, + ) + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.subtle, + ), + width: BorderWidth.regular, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.travel_explore, + size: IconSize.small, + color: widget.enabled + ? (webSearchEnabled + ? context.conduitTheme.textPrimary + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.strong, + )) + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.disabled, + ), + ), + const SizedBox(width: Spacing.sm), + Flexible( + child: Text( + 'Search', + style: TextStyle( + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w500, + color: widget.enabled + ? (webSearchEnabled + ? context.conduitTheme.textPrimary + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.strong, + )) + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.disabled, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildWebSearchStatusIndicator() { + final webSearchEnabled = ref.watch( + webSearchEnabledProvider.select((enabled) => enabled), + ); + + if (!webSearchEnabled) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + margin: const EdgeInsets.only( + left: Spacing.md, + right: Spacing.md, + bottom: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.info.withValues( + alpha: Alpha.badgeBackground, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.badge), + border: Border.all( + color: context.conduitTheme.info.withValues(alpha: Alpha.subtle), + width: BorderWidth.regular, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.travel_explore, + size: IconSize.small, + color: context.conduitTheme.info, + ), + const SizedBox(width: Spacing.xs), + Text( + 'Web search on', + style: AppTypography.captionStyle.copyWith( + color: context.conduitTheme.info, + ), + ), + ], + ), + ); + } + + void _showAttachmentOptions() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.bottomSheet), + ), + boxShadow: ConduitShadows.modal, + ), + padding: const EdgeInsets.all(Spacing.bottomSheetPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: context.conduitTheme.textPrimary.withValues( + alpha: Alpha.medium, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: Spacing.lg), + + // Options grid + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildAttachmentOption( + icon: Platform.isIOS ? CupertinoIcons.doc : Icons.attach_file, + label: 'File', + onTap: () { + Navigator.pop(context); // Close modal + widget.onFileAttachment?.call(); + }, + ), + _buildAttachmentOption( + icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image, + label: 'Photo', + onTap: () { + Navigator.pop(context); // Close modal + widget.onImageAttachment?.call(); + }, + ), + _buildAttachmentOption( + icon: Platform.isIOS + ? CupertinoIcons.camera + : Icons.camera_alt, + label: 'Camera', + onTap: () { + Navigator.pop(context); // Close modal + widget.onCameraCapture?.call(); + }, + ), + ], + ), + const SizedBox(height: Spacing.lg), + ], + ), + ), + ); + } + + Widget _buildAttachmentOption({ + required IconData icon, + required String label, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: context.conduitTheme.textPrimary.withValues( + alpha: Alpha.subtle, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: context.conduitTheme.textPrimary.withValues( + alpha: Alpha.subtle, + ), + width: BorderWidth.regular, + ), + ), + child: Icon( + icon, + color: context.conduitTheme.textPrimary, + size: IconSize.xl, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + label, + style: AppTypography.labelStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/chat/widgets/modern_message_bubble.dart b/lib/features/chat/widgets/modern_message_bubble.dart new file mode 100644 index 0000000..8577c5e --- /dev/null +++ b/lib/features/chat/widgets/modern_message_bubble.dart @@ -0,0 +1,811 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:io' show Platform; +import '../../../core/providers/app_providers.dart'; + +class ModernMessageBubble extends ConsumerStatefulWidget { + final dynamic message; + final bool isUser; + final bool isStreaming; + final String? modelName; + final VoidCallback? onCopy; + final VoidCallback? onEdit; + final VoidCallback? onRegenerate; + final VoidCallback? onLike; + final VoidCallback? onDislike; + + const ModernMessageBubble({ + super.key, + required this.message, + required this.isUser, + this.isStreaming = false, + this.modelName, + this.onCopy, + this.onEdit, + this.onRegenerate, + this.onLike, + this.onDislike, + }); + + @override + ConsumerState createState() => + _ModernMessageBubbleState(); +} + +class _ModernMessageBubbleState extends ConsumerState + with TickerProviderStateMixin { + bool _showActions = false; + late AnimationController _fadeController; + late AnimationController _slideController; + static const int _maxCachedImages = 24; + + // Cache for image base64 data to prevent repeated API calls + final Map _imageCache = {}; + + @override + void initState() { + super.initState(); + _fadeController = AnimationController( + duration: AnimationDuration.microInteraction, + vsync: this, + ); + _slideController = AnimationController( + duration: AnimationDuration.messageSlide, + vsync: this, + ); + } + + @override + void dispose() { + _fadeController.dispose(); + _slideController.dispose(); + super.dispose(); + } + + void _toggleActions() { + setState(() { + _showActions = !_showActions; + }); + + if (_showActions) { + _fadeController.forward(); + _slideController.forward(); + } else { + _fadeController.reverse(); + _slideController.reverse(); + } + } + + @override + Widget build(BuildContext context) { + if (widget.isUser) { + return _buildUserMessage(); + } else { + return _buildAssistantMessage(); + } + } + + Widget _buildUserMessage() { + return Container( + width: double.infinity, + margin: const EdgeInsets.only( + bottom: Spacing.messagePadding, + left: Spacing.xxxl, + right: Spacing.xs, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: GestureDetector( + onLongPress: () => _toggleActions(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.messagePadding, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + context.conduitTheme.chatBubbleUser.withValues( + alpha: 0.95, + ), + context.conduitTheme.chatBubbleUser, + ], + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.messageBubble, + ), + border: Border.all( + color: context.conduitTheme.chatBubbleUserBorder, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.high, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Display images if any + if (widget.message.attachmentIds != null && + widget.message.attachmentIds!.isNotEmpty) + _buildAttachmentImages(), + + // Display text content if any + if (widget.message.content.isNotEmpty) ...[ + if (widget.message.attachmentIds != null && + widget.message.attachmentIds!.isNotEmpty) + const SizedBox(height: Spacing.sm), + _buildCustomText( + widget.message.content, + context.conduitTheme.chatBubbleUserText, + ), + ], + ], + ), + ), + ), + ), + ], + ), + ) + .animate() + .fadeIn(duration: AnimationDuration.messageAppear) + .slideX( + begin: AnimationValues.messageSlideDistance, + end: 0, + duration: AnimationDuration.messageSlide, + curve: AnimationCurves.messageSlide, + ); + } + + Widget _buildAssistantMessage() { + return Container( + width: double.infinity, + margin: const EdgeInsets.only( + bottom: Spacing.lg, + left: Spacing.xs, + right: Spacing.xxxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Simplified AI Name and Avatar + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + context.conduitTheme.buttonPrimary.withValues( + alpha: 0.9, + ), + context.conduitTheme.buttonPrimary, + ], + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.small, + ), + ), + child: Icon( + Icons.auto_awesome, + color: context.conduitTheme.buttonPrimaryText, + size: 12, + ), + ), + const SizedBox(width: Spacing.xs), + Text( + widget.modelName ?? 'Assistant', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + ), + ], + ), + ), + + // Message Content + GestureDetector( + onLongPress: () => _toggleActions(), + child: Container( + padding: const EdgeInsets.all(Spacing.messagePadding), + decoration: BoxDecoration( + color: context.conduitTheme.chatBubbleAssistant, + borderRadius: BorderRadius.circular( + AppBorderRadius.messageBubble, + ), + border: Border.all( + color: context.conduitTheme.chatBubbleAssistantBorder, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.low, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Check for typing indicator - show for empty content OR explicit typing indicator during streaming + if ((widget.message.content.isEmpty || + widget.message.content == '[TYPING_INDICATOR]') && + widget.isStreaming) ...[ + _buildTypingIndicator(), + ] else if (widget.message.content.isNotEmpty && + widget.message.content != '[TYPING_INDICATOR]') ...[ + _buildCustomText( + widget.message.content, + context.conduitTheme.chatBubbleAssistantText, + ), + ] else + // Fallback: show empty state for non-streaming empty messages + const SizedBox.shrink(), + + // Action buttons + if (_showActions) ...[ + const SizedBox(height: Spacing.md), + _buildActionButtons(), + ], + ], + ), + ), + ), + ], + ), + ) + .animate() + .fadeIn(duration: AnimationDuration.messageAppear) + .slideX( + begin: -AnimationValues.messageSlideDistance, + end: 0, + duration: AnimationDuration.messageSlide, + curve: AnimationCurves.messageSlide, + ); + } + + Widget _buildAttachmentImages() { + if (widget.message.attachmentIds == null || + widget.message.attachmentIds!.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.message.attachmentIds!.map((attachmentId) { + return Consumer( + builder: (context, ref, child) { + final api = ref.watch(apiServiceProvider); + if (api == null) return const SizedBox.shrink(); + + return FutureBuilder( + future: _getCachedImageBase64(api, attachmentId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + height: 150, + width: 200, + margin: const EdgeInsets.only(bottom: Spacing.xs), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Center( + child: CircularProgressIndicator( + color: context.conduitTheme.buttonPrimary, + strokeWidth: 2, + ), + ), + ); + } + + if (snapshot.hasError || + !snapshot.hasData || + snapshot.data == null) { + return Container( + height: 100, + width: 150, + margin: const EdgeInsets.only(bottom: Spacing.xs), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all( + color: context.conduitTheme.textSecondary.withValues( + alpha: 0.3, + ), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.broken_image_outlined, + color: context.conduitTheme.textSecondary, + size: 32, + ), + const SizedBox(height: Spacing.xs), + Text( + 'Image unavailable', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + ), + ), + ], + ), + ); + } + + final base64Data = snapshot.data!; + try { + // Handle data URLs (data:image/...;base64,...) + String actualBase64; + if (base64Data.startsWith('data:')) { + // Extract base64 part from data URL + final commaIndex = base64Data.indexOf(','); + if (commaIndex != -1) { + actualBase64 = base64Data.substring(commaIndex + 1); + } else { + throw Exception('Invalid data URL format'); + } + } else { + // Direct base64 string + actualBase64 = base64Data; + } + + final imageBytes = base64.decode(actualBase64); + return Container( + margin: const EdgeInsets.only(bottom: Spacing.xs), + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 300, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + child: Image.memory( + imageBytes, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 100, + width: 150, + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular( + AppBorderRadius.sm, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: context.conduitTheme.error, + size: 32, + ), + const SizedBox(height: Spacing.xs), + Text( + 'Failed to load image', + style: TextStyle( + color: context.conduitTheme.error, + fontSize: AppTypography.bodySmall, + ), + ), + ], + ), + ); + }, + ), + ), + ); + } catch (e) { + return Container( + height: 100, + width: 150, + margin: const EdgeInsets.only(bottom: Spacing.xs), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: context.conduitTheme.error, + size: 32, + ), + const SizedBox(height: Spacing.xs), + Text( + 'Invalid image format', + style: TextStyle( + color: context.conduitTheme.error, + fontSize: AppTypography.bodySmall, + ), + ), + ], + ), + ); + } + }, + ); + }, + ); + }).toList(), + ); + } + + Future _getCachedImageBase64(dynamic api, String fileId) async { + // Check cache first to prevent repeated API calls + if (_imageCache.containsKey(fileId)) { + return _imageCache[fileId]; + } + + // If not in cache, get the image and cache it + final result = await _getImageBase64(api, fileId); + // Simple LRU-like eviction to bound memory + if (_imageCache.length >= _maxCachedImages) { + _imageCache.remove(_imageCache.keys.first); + } + _imageCache[fileId] = result; + return result; + } + + Future _getImageBase64(dynamic api, String fileId) async { + try { + // Check if this is already a data URL (for images) + if (fileId.startsWith('data:')) { + return fileId; + } + + // First, get file info to determine if it's an image + final fileInfo = await api.getFileInfo(fileId); + final fileName = + fileInfo['filename'] ?? + fileInfo['meta']?['name'] ?? + fileInfo['name'] ?? + fileInfo['file_name'] ?? + fileInfo['original_name'] ?? + fileInfo['original_filename'] ?? + ''; + final ext = fileName.toLowerCase().split('.').last; + + // Only process image files + if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) { + debugPrint('DEBUG: Skipping non-image file: $fileName'); + return null; + } + + // Get file content as base64 string + final fileContent = await api.getFileContent(fileId); + return fileContent; + } catch (e) { + debugPrint('DEBUG: Error getting image content for $fileId: $e'); + return null; + } + } + + Widget _buildCustomText(String text, [Color? textColor]) { + // Simple markdown-like parsing for efficiency + final lines = text.split('\n'); + final widgets = []; + + for (int i = 0; i < lines.length; i++) { + final line = lines[i]; + if (line.trim().isEmpty) { + if (i < lines.length - 1) { + widgets.add(const SizedBox(height: Spacing.sm)); + } + continue; + } + + // Parse basic markdown + Widget textWidget = _parseMarkdownLine(line, textColor); + + if (i < lines.length - 1) { + widgets.add(textWidget); + widgets.add(const SizedBox(height: Spacing.xs)); + } else { + widgets.add(textWidget); + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets, + ); + } + + Widget _parseMarkdownLine(String line, [Color? textColor]) { + // Handle code blocks + if (line.startsWith('```')) { + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.xs), + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues( + alpha: Alpha.badgeBackground, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all( + color: context.conduitTheme.textPrimary.withValues( + alpha: Alpha.subtle, + ), + width: BorderWidth.regular, + ), + ), + child: Text( + line.substring(3), + style: AppTypography.chatCodeStyle.copyWith( + color: textColor ?? context.conduitTheme.textSecondary, + ), + ), + ) + .animate() + .fadeIn(duration: AnimationDuration.microInteraction) + .slideX( + begin: 0.1, + end: 0, + duration: AnimationDuration.microInteraction, + ); + } + + // Handle headers + if (line.startsWith('#')) { + int level = 0; + while (level < line.length && line[level] == '#') { + level++; + } + final fontSize = AppTypography.headlineMedium - (level * 2); + return Text( + line.substring(level).trim(), + style: AppTypography.headlineMediumStyle.copyWith( + color: textColor ?? context.conduitTheme.textPrimary, + fontSize: fontSize.toDouble(), + ), + ) + .animate() + .fadeIn(duration: AnimationDuration.microInteraction) + .slideX( + begin: 0.1, + end: 0, + duration: AnimationDuration.microInteraction, + ); + } + + // Handle inline code + if (line.contains('`')) { + final parts = line.split('`'); + final widgets = []; + + for (int i = 0; i < parts.length; i++) { + if (parts[i].isNotEmpty) { + if (i % 2 == 1) { + // Inline code + widgets.add( + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs + Spacing.xxs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.textPrimary.withValues( + alpha: Alpha.badgeBackground, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + child: Text( + parts[i], + style: AppTypography.chatCodeStyle.copyWith( + color: textColor ?? context.conduitTheme.textSecondary, + ), + ), + ), + ); + } else { + // Regular text + widgets.add( + Text( + parts[i], + style: AppTypography.chatMessageStyle.copyWith( + color: textColor ?? context.conduitTheme.textPrimary, + ), + ), + ); + } + } + } + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.start, + children: widgets, + ) + .animate() + .fadeIn(duration: AnimationDuration.microInteraction) + .slideX( + begin: 0.1, + end: 0, + duration: AnimationDuration.microInteraction, + ); + } + + // Regular text + return Text( + line, + style: AppTypography.chatMessageStyle.copyWith( + color: textColor ?? context.conduitTheme.textPrimary, + letterSpacing: 0.1, + ), + ) + .animate() + .fadeIn(duration: AnimationDuration.microInteraction) + .slideX( + begin: 0.1, + end: 0, + duration: AnimationDuration.microInteraction, + ); + } + + Widget _buildTypingIndicator() { + return Consumer( + builder: (context, ref, child) { + // Show only animated dots, no text + return _buildTypingDots(); + }, + ); + } + + Widget _buildTypingDots() { + return Row( + children: List.generate(3, (index) { + return Container( + margin: EdgeInsets.only(right: index < 2 ? Spacing.xs : 0), + width: 6, + height: 6, + decoration: BoxDecoration( + color: context.conduitTheme.loadingIndicator, + borderRadius: BorderRadius.circular(3), + ), + ) + .animate(onPlay: (controller) => controller.repeat()) + .scale( + duration: AnimationDuration.typingIndicator, + begin: const Offset( + AnimationValues.typingIndicatorScale, + AnimationValues.typingIndicatorScale, + ), + end: const Offset(1.0, 1.0), + curve: AnimationCurves.typingIndicator, + delay: Duration( + milliseconds: index * 200, + ), // Stagger the animation + ); + }), + ); + } + + Widget _buildActionButtons() { + return Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + _buildActionButton( + icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined, + label: 'Edit', + onTap: widget.onEdit, + ), + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.doc_on_clipboard + : Icons.content_copy, + label: 'Copy', + onTap: widget.onCopy, + ), + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.speaker_1 + : Icons.volume_up_outlined, + label: 'Read', + onTap: () => _handleTextToSpeech(context), + ), + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.hand_thumbsup + : Icons.thumb_up_outlined, + label: 'Like', + onTap: widget.onLike, + ), + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.hand_thumbsdown + : Icons.thumb_down_outlined, + label: 'Dislike', + onTap: widget.onDislike, + ), + _buildActionButton( + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + label: 'Regenerate', + onTap: widget.onRegenerate, + ), + ], + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.actionButtonPadding, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues( + alpha: Alpha.buttonHover, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.actionButton), + border: Border.all( + color: context.conduitTheme.textPrimary.withValues( + alpha: Alpha.subtle, + ), + width: BorderWidth.regular, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: IconSize.small, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.xs), + Text( + label, + style: AppTypography.labelStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + ).animate().scale( + duration: AnimationDuration.buttonPress, + curve: AnimationCurves.buttonPress, + ); + } + + void _handleTextToSpeech(BuildContext context) { + // Implementation for text-to-speech functionality + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Text-to-speech feature coming soon!'), + backgroundColor: context.conduitTheme.info, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.snackbar), + ), + ), + ); + } +} diff --git a/lib/features/chat/widgets/tag_management_dialog.dart b/lib/features/chat/widgets/tag_management_dialog.dart new file mode 100644 index 0000000..23888b1 --- /dev/null +++ b/lib/features/chat/widgets/tag_management_dialog.dart @@ -0,0 +1,259 @@ +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/conversation.dart'; +import '../../../core/providers/app_providers.dart'; + +class TagManagementDialog extends ConsumerStatefulWidget { + final Conversation conversation; + + const TagManagementDialog({super.key, required this.conversation}); + + @override + ConsumerState createState() => + _TagManagementDialogState(); +} + +class _TagManagementDialogState extends ConsumerState { + final _tagController = TextEditingController(); + bool _isAdding = false; + + @override + void dispose() { + _tagController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final conversationTags = widget.conversation.tags; + + return Dialog( + child: Container( + width: 400, + constraints: const BoxConstraints(maxHeight: 500), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.lg), + ), + ), + child: Row( + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.tag : Icons.label, + color: theme.colorScheme.onPrimaryContainer, + ), + const SizedBox(width: Spacing.sm), + Text( + 'Manage Tags', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: Icon( + Platform.isIOS ? CupertinoIcons.xmark : Icons.close, + color: theme.colorScheme.onPrimaryContainer, + ), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // Add new tag section + Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _tagController, + decoration: InputDecoration( + hintText: 'Add new tag', + border: const OutlineInputBorder(), + prefixIcon: Icon( + Platform.isIOS + ? CupertinoIcons.tag_fill + : Icons.label, + ), + ), + onSubmitted: (_) => _addTag(), + ), + ), + const SizedBox(width: Spacing.sm), + ElevatedButton( + onPressed: _isAdding ? null : _addTag, + child: _isAdding + ? const SizedBox( + width: Spacing.md, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Add'), + ), + ], + ), + ), + + const Divider(height: 1), + + // Current tags + Expanded( + child: conversationTags.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.tag + : Icons.label_outline, + size: 48, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.3, + ), + ), + const SizedBox(height: Spacing.md), + Text( + 'No tags yet', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + ), + ), + const SizedBox(height: Spacing.sm), + Text( + 'Add tags to organize and find conversations easily', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.5, + ), + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(Spacing.md), + itemCount: conversationTags.length, + itemBuilder: (context, index) { + final tag = conversationTags[index]; + return _buildTagChip(context, tag); + }, + ), + ), + + // Bottom actions + Padding( + padding: const EdgeInsets.all(Spacing.md), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Done'), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTagChip(BuildContext context, String tag) { + final theme = Theme.of(context); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: Chip( + avatar: Icon( + Platform.isIOS ? CupertinoIcons.tag_fill : Icons.label, + size: 16, + color: theme.colorScheme.onPrimaryContainer, + ), + label: Text(tag), + backgroundColor: theme.colorScheme.primaryContainer, + deleteIcon: Icon( + Platform.isIOS ? CupertinoIcons.xmark_circle_fill : Icons.cancel, + size: 18, + ), + onDeleted: () => _removeTag(tag), + ), + ); + } + + Future _addTag() async { + final tag = _tagController.text.trim(); + if (tag.isEmpty || widget.conversation.tags.contains(tag)) return; + + setState(() => _isAdding = true); + + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service available'); + + await api.addTagToConversation(widget.conversation.id, tag); + ref.invalidate(conversationsProvider); + _tagController.clear(); + + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Tag "$tag" added'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error adding tag: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + setState(() => _isAdding = false); + } + } + + Future _removeTag(String tag) async { + try { + final api = ref.read(apiServiceProvider); + if (api == null) throw Exception('No API service available'); + + await api.removeTagFromConversation(widget.conversation.id, tag); + ref.invalidate(conversationsProvider); + + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Tag "$tag" removed'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error removing tag: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } +} diff --git a/lib/features/files/views/files_page.dart b/lib/features/files/views/files_page.dart new file mode 100644 index 0000000..1688991 --- /dev/null +++ b/lib/features/files/views/files_page.dart @@ -0,0 +1,439 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../core/services/navigation_service.dart'; +import '../../../shared/widgets/improved_loading_states.dart'; + +import '../../../shared/utils/ui_utils.dart'; + +/// Files page for managing documents and uploads +class FilesPage extends ConsumerStatefulWidget { + const FilesPage({super.key}); + + @override + ConsumerState createState() => _FilesPageState(); +} + +class _FilesPageState extends ConsumerState + with TickerProviderStateMixin { + int _selectedTab = 0; + late AnimationController _tabAnimationController; + late AnimationController _contentAnimationController; + + @override + void initState() { + super.initState(); + _tabAnimationController = AnimationController( + duration: AnimationDuration.microInteraction, + vsync: this, + ); + _contentAnimationController = AnimationController( + duration: AnimationDuration.pageTransition, + vsync: this, + ); + } + + @override + void dispose() { + _tabAnimationController.dispose(); + _contentAnimationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ErrorBoundary( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: _buildAppBar(), + body: Column( + children: [ + // Enhanced tab selector with animations + _buildTabSelector().animate().fadeIn( + duration: AnimationDuration.fast, + delay: AnimationDelay.short, + ), + + // Animated content + Expanded( + child: AnimatedSwitcher( + duration: AnimationDuration.pageTransition, + transitionBuilder: (Widget child, Animation animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: + Tween( + begin: const Offset(0.05, 0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: AnimationCurves.pageTransition, + ), + ), + child: child, + ), + ); + }, + child: _selectedTab == 0 + ? _buildRecentFiles() + : _buildKnowledgeBase(), + ), + ), + ], + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: Elevation.none, + automaticallyImplyLeading: false, + toolbarHeight: TouchTarget.appBar, + titleSpacing: 0.0, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Files', + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + centerTitle: true, + actions: [ + // Enhanced upload button with proper touch target + Container( + width: TouchTarget.iconButton, + height: TouchTarget.iconButton, + margin: const EdgeInsets.only(right: Spacing.screenPadding), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.button), + onTap: _showUploadOptions, + child: Icon( + UiUtils.addIcon, + color: context.conduitTheme.iconPrimary, + size: IconSize.button, + ), + ), + ), + ), + ], + ); + } + + Widget _buildTabSelector() { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: Spacing.pagePadding, + vertical: Spacing.sm, + ), + padding: const EdgeInsets.all(Spacing.xs), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.thin, + ), + boxShadow: ConduitShadows.card, + ), + child: Row( + children: [ + Expanded( + child: _buildTabButton( + index: 0, + label: 'Recent Files', + isSelected: _selectedTab == 0, + ), + ), + const SizedBox(width: Spacing.xs), + Expanded( + child: _buildTabButton( + index: 1, + label: 'Knowledge Base', + isSelected: _selectedTab == 1, + ), + ), + ], + ), + ); + } + + Widget _buildTabButton({ + required int index, + required String label, + required bool isSelected, + }) { + return GestureDetector( + onTap: () { + setState(() => _selectedTab = index); + _tabAnimationController.forward(from: 0); + _contentAnimationController.forward(from: 0); + }, + child: AnimatedContainer( + duration: AnimationDuration.microInteraction, + curve: AnimationCurves.buttonPress, + padding: const EdgeInsets.symmetric( + vertical: Spacing.buttonPadding, + horizontal: Spacing.md, + ), + decoration: BoxDecoration( + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.surfaceBackground.withValues( + alpha: Alpha.hover, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.button), + boxShadow: isSelected ? ConduitShadows.button : null, + ), + child: Text( + label, + style: context.conduitTheme.label?.copyWith( + color: isSelected + ? context.conduitTheme.textInverse + : context.conduitTheme.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ); + } + + Widget _buildRecentFiles() { + return Container( + key: const ValueKey('recent_files'), + padding: const EdgeInsets.all(Spacing.pagePadding), + child: ImprovedEmptyState( + icon: UiUtils.platformIcon( + ios: CupertinoIcons.doc, + android: Icons.description_outlined, + ), + title: 'No files yet', + subtitle: + 'Upload documents to reference in your conversations with Conduit', + onAction: _showUploadOptions, + actionLabel: 'Upload your first file', + showAnimation: true, + ), + ).animate().fadeIn( + duration: AnimationDuration.messageAppear, + delay: AnimationDelay.short, + ); + } + + Widget _buildKnowledgeBase() { + return Container( + key: const ValueKey('knowledge_base'), + padding: const EdgeInsets.all(Spacing.pagePadding), + child: ImprovedEmptyState( + icon: UiUtils.platformIcon( + ios: CupertinoIcons.book, + android: Icons.library_books, + ), + title: 'Knowledge base is empty', + subtitle: 'Create collections of related documents for easy reference', + onAction: _showKnowledgeBaseOptions, + actionLabel: 'Create knowledge base', + showAnimation: true, + ), + ).animate().fadeIn( + duration: AnimationDuration.messageAppear, + delay: AnimationDelay.short, + ); + } + + void _showUploadOptions() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => _buildUploadModal(), + ); + } + + Widget _buildUploadModal() { + return Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(AppBorderRadius.modal), + topRight: Radius.circular(AppBorderRadius.modal), + ), + boxShadow: ConduitShadows.modal, + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Enhanced handle bar + Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + width: 40, + height: 4, + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + + // Header with enhanced typography + Padding( + padding: const EdgeInsets.all(Spacing.modalPadding), + child: Text( + 'Upload File', + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + + // Enhanced upload options + _buildUploadOption( + icon: UiUtils.platformIcon( + ios: CupertinoIcons.camera, + android: Icons.camera_alt, + ), + title: 'Take Photo', + subtitle: 'Capture a document or image', + onTap: () => _handleUploadOption('camera'), + ), + _buildUploadOption( + icon: UiUtils.platformIcon( + ios: CupertinoIcons.photo, + android: Icons.photo_library, + ), + title: 'Photo Library', + subtitle: 'Choose from your photos', + onTap: () => _handleUploadOption('gallery'), + ), + _buildUploadOption( + icon: UiUtils.platformIcon( + ios: CupertinoIcons.doc, + android: Icons.description, + ), + title: 'Document', + subtitle: 'PDF, Word, or text file', + onTap: () => _handleUploadOption('document'), + ), + + const SizedBox(height: Spacing.modalPadding), + ], + ), + ), + ).animate().slide( + duration: AnimationDuration.modalPresentation, + curve: AnimationCurves.modalPresentation, + begin: const Offset(0, 1), + end: Offset.zero, + ); + } + + Widget _buildUploadOption({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: Spacing.modalPadding, + vertical: Spacing.xs, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.card), + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(Spacing.listItemPadding), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.thin, + ), + ), + child: Row( + children: [ + // Enhanced icon container + Container( + width: IconSize.avatar, + height: IconSize.avatar, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.highlight, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.avatar), + ), + child: Icon( + icon, + color: context.conduitTheme.buttonPrimary, + size: IconSize.medium, + ), + ), + const SizedBox(width: Spacing.md), + // Enhanced text content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: Spacing.xs), + Text( + subtitle, + style: context.conduitTheme.caption?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.chevron_right, + android: Icons.chevron_right, + ), + color: context.conduitTheme.iconSecondary, + size: IconSize.small, + ), + ], + ), + ), + ), + ), + ).animate().fadeIn( + duration: AnimationDuration.fast, + delay: AnimationDelay.staggeredDelay, + ); + } + + void _handleUploadOption(String type) { + NavigationService.goBack(); + UiUtils.showMessage(context, 'File upload for $type is coming soon!'); + } + + void _showKnowledgeBaseOptions() { + UiUtils.showMessage(context, 'Knowledge base creation is coming soon!'); + } +} diff --git a/lib/features/navigation/views/chats_list_page.dart b/lib/features/navigation/views/chats_list_page.dart new file mode 100644 index 0000000..c9d401d --- /dev/null +++ b/lib/features/navigation/views/chats_list_page.dart @@ -0,0 +1,1362 @@ +import 'package:flutter/material.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../core/services/focus_management_service.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../../../shared/widgets/loading_states.dart'; +import 'dart:async'; +import 'dart:io' show Platform; + +import '../../../core/providers/app_providers.dart'; +import '../../../shared/widgets/themed_dialogs.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import '../../chat/providers/chat_providers.dart'; +import '../../chat/widgets/folder_management_dialog.dart'; + +/// Optimized conversation list page with Conduit design aesthetics +class ChatsListPage extends ConsumerStatefulWidget { + final bool isOverlay; + + const ChatsListPage({super.key, this.isOverlay = false}); + + @override + ConsumerState createState() => _ChatsListPageState(); +} + +class _ChatsListPageState extends ConsumerState + with AutomaticKeepAliveClientMixin { + final TextEditingController _searchController = TextEditingController(); + late final FocusNode _searchFocusNode; + final ScrollController _scrollController = ScrollController(); + + // Debounce search to improve performance + String _searchQuery = ''; + Timer? _debounceTimer; + bool _isLoadingConversation = false; + bool _hasAddedFocusListener = false; + + // Provider for archived section visibility + static final _showArchivedProvider = StateProvider((ref) => false); + + @override + bool get wantKeepAlive => true; // Keep state alive for better performance + + @override + void initState() { + super.initState(); + _searchFocusNode = FocusManagementService.registerFocusNode( + 'chats_list_search', + debugLabel: 'Chats List Search', + ); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); + _scrollController.dispose(); + _debounceTimer?.cancel(); + FocusManagementService.disposeFocusNode('chats_list_search'); + super.dispose(); + } + + void _onSearchChanged() { + // Cancel previous timer + _debounceTimer?.cancel(); + + // Set new timer for debounced search + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + if (_searchQuery != _searchController.text) { + setState(() { + _searchQuery = _searchController.text; + }); + ref.read(searchQueryProvider.notifier).state = _searchQuery; + } + }); + } + + @override + Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin + + return ErrorBoundary( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: _buildAppBar(), + body: Column( + children: [ + _buildSearchBar(), + Expanded(child: _wrapWithRefresh(_buildConversationsList())), + _buildBottomActions(), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: _startNewChat, + backgroundColor: context.conduitTheme.buttonPrimary, + foregroundColor: context.conduitTheme.buttonPrimaryText, + elevation: Elevation.medium, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.floatingButton), + ), + child: Icon( + Platform.isIOS ? CupertinoIcons.plus : Icons.add_rounded, + size: IconSize.large, + ), + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: Elevation.none, + scrolledUnderElevation: Elevation.none, + leading: widget.isOverlay + ? ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close_rounded, + onPressed: () => Navigator.pop(context), + ) + : ConduitIconButton( + icon: Platform.isIOS + ? CupertinoIcons.back + : Icons.arrow_back_rounded, + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Chats', + style: AppTypography.headlineMediumStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + actions: [ + ConduitIconButton( + icon: Platform.isIOS + ? CupertinoIcons.ellipsis + : Icons.more_vert_rounded, + onPressed: _showOptions, + ), + ], + ); + } + + Widget _buildSearchBar() { + // Listen to focus changes and update UI + final isFocused = _searchFocusNode.hasFocus; + + // Attach listener only once + if (!_hasAddedFocusListener) { + _searchFocusNode.addListener(() { + setState(() {}); + }); + _hasAddedFocusListener = true; + } + + return GestureDetector( + onTap: () { + // Focus the search field when the container is tapped + _searchFocusNode.requestFocus(); + }, + child: Container( + margin: const EdgeInsets.all(Spacing.pagePadding), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.inputPadding, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + color: context.conduitTheme.inputBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.input), + border: Border.all( + color: isFocused + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.inputBorder, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.input, + ), + child: Row( + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search_rounded, + size: IconSize.medium, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.inputText, + ), + decoration: InputDecoration( + hintText: 'Search conversations...', + hintStyle: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.inputPlaceholder, + ), + border: InputBorder.none, // Remove default border + focusedBorder: + InputBorder.none, // Remove default focus border + enabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), + ), + if (_searchController.text.isNotEmpty) + ConduitIconButton( + icon: Platform.isIOS + ? CupertinoIcons.clear + : Icons.clear_rounded, + onPressed: () { + _searchController.clear(); + _searchQuery = ''; + ref.read(searchQueryProvider.notifier).state = ''; + }, + ), + ], + ), + ), + ).animate().fadeIn( + duration: AnimationDuration.microInteraction, + curve: AnimationCurves.microInteraction, + ); + } + + Widget _buildConversationsList() { + return Consumer( + builder: (context, ref, child) { + final conversationsAsync = ref.watch(conversationsProvider); + + return conversationsAsync.when( + data: (conversations) { + if (conversations.isEmpty) { + return _buildEmptyState(); + } + + final filteredConversations = _filterConversations(conversations); + + if (filteredConversations.isEmpty) { + return _buildNoResultsState(); + } + + // Separate conversations by status + final pinnedConversations = filteredConversations + .where((c) => c.pinned == true) + .toList(); + final regularConversations = filteredConversations + .where((c) => c.pinned != true && c.archived != true) + .toList(); + final archivedConversations = filteredConversations + .where((c) => c.archived == true) + .toList(); + + return ListView( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.pagePadding, + vertical: Spacing.sm, + ), + children: [ + // Pinned conversations section + if (pinnedConversations.isNotEmpty) ...[ + _buildSectionHeader('Pinned', pinnedConversations.length), + ...pinnedConversations.asMap().entries.map((entry) { + return _buildConversationTile( + entry.value, + entry.key, + isPinned: true, + ); + }), + const SizedBox(height: Spacing.lg), + ], + + // Regular conversations section + if (regularConversations.isNotEmpty) ...[ + _buildSectionHeader('Recent', regularConversations.length), + ...regularConversations.asMap().entries.map((entry) { + return _buildConversationTile(entry.value, entry.key); + }), + ], + + // Archived conversations section (collapsed by default) + if (archivedConversations.isNotEmpty) ...[ + const SizedBox(height: Spacing.lg), + _buildArchivedSection(archivedConversations), + ], + ], + ); + }, + loading: () => _buildLoadingState(), + error: (error, stackTrace) => _buildErrorState(error), + ); + }, + ); + } + + Widget _wrapWithRefresh(Widget child) { + return ConduitRefreshIndicator( + onRefresh: () async { + ref.invalidate(conversationsProvider); + await Future.delayed(const Duration(milliseconds: 500)); + }, + child: child, + ); + } + + Widget _buildConversationTile( + dynamic conversation, + int index, { + bool isPinned = false, + bool isArchived = false, + }) { + final isSelected = + ref.watch(activeConversationProvider)?.id == conversation.id; + // TODO: Use pinned status for future conversation management features + // final conversationIsPinned = conversation.pinned ?? false; + final isLoading = _isLoadingConversation && isSelected; + + return Container( + margin: const EdgeInsets.only(bottom: Spacing.listGap), + decoration: BoxDecoration( + gradient: isSelected + ? LinearGradient( + colors: [ + context.conduitTheme.navigationSelectedBackground.withValues( + alpha: 0.15, + ), + context.conduitTheme.navigationSelectedBackground.withValues( + alpha: 0.05, + ), + ], + ) + : null, + color: isSelected + ? null + : isArchived + ? context.conduitTheme.surfaceContainer.withValues(alpha: 0.3) + : context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: isSelected + ? context.conduitTheme.navigationSelected + : isArchived + ? context.conduitTheme.dividerColor.withValues(alpha: 0.5) + : context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + boxShadow: isSelected ? ConduitShadows.high : ConduitShadows.low, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: isLoading ? null : () => _selectConversation(conversation), + onLongPress: isLoading + ? null + : () => _showConversationOptions(conversation), + borderRadius: BorderRadius.circular(AppBorderRadius.card), + child: Padding( + padding: const EdgeInsets.all(Spacing.listItemPadding), + child: Row( + children: [ + // Conversation icon/avatar + Container( + width: IconSize.avatar, + height: IconSize.avatar, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + borderRadius: BorderRadius.circular(AppBorderRadius.avatar), + boxShadow: ConduitShadows.card, + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.chat_bubble + : Icons.chat_rounded, + size: IconSize.medium, + color: context.conduitTheme.buttonPrimaryText, + ), + ), + const SizedBox(width: Spacing.md), + + // Conversation details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + conversation.title ?? 'New Chat', + style: AppTypography.bodyLargeStyle.copyWith( + color: isArchived + ? context.conduitTheme.textSecondary + : context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isPinned) + Icon( + Platform.isIOS + ? CupertinoIcons.pin_fill + : Icons.push_pin, + size: IconSize.small, + color: context.conduitTheme.warning, + ), + ], + ), + const SizedBox(height: Spacing.xs), + Text( + _getConversationPreview(conversation), + style: AppTypography.bodySmallStyle.copyWith( + color: isArchived + ? context.conduitTheme.textTertiary + : context.conduitTheme.textSecondary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: Spacing.xs), + Text( + _formatConversationDate(conversation.updatedAt), + style: AppTypography.captionStyle.copyWith( + color: isArchived + ? context.conduitTheme.textTertiary.withValues( + alpha: 0.5, + ) + : context.conduitTheme.textTertiary, + ), + ), + ], + ), + ), + + // Action buttons + Column( + children: [ + if (isLoading) + SizedBox( + width: IconSize.medium, + height: IconSize.medium, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.buttonPrimary, + ), + ), + ) + else ...[ + ConduitIconButton( + icon: Platform.isIOS + ? CupertinoIcons.ellipsis + : Icons.more_vert_rounded, + onPressed: () => _showConversationOptions(conversation), + ), + if (conversation.messages.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + ), + child: Text( + conversation.messages.length.toString(), + style: AppTypography.captionStyle.copyWith( + color: context.conduitTheme.buttonPrimaryText, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ], + ), + ), + ), + ), + ).animate().fadeIn( + duration: AnimationDuration.messageAppear, + delay: Duration( + milliseconds: index * AnimationDelay.staggeredDelay.inMilliseconds, + ), + curve: AnimationCurves.messageSlide, + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.chat_bubble : Icons.chat_rounded, + size: IconSize.xxl, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(height: Spacing.lg), + Text( + 'No conversations yet', + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + 'Start a new chat to begin your conversation', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.xl), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _startNewChat, + style: ElevatedButton.styleFrom( + backgroundColor: context.conduitTheme.buttonPrimary, + foregroundColor: context.conduitTheme.buttonPrimaryText, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.buttonPadding, + vertical: Spacing.md, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.button), + ), + elevation: Elevation.none, + ), + child: Text( + 'Start New Chat', + style: AppTypography.labelStyle.copyWith( + color: context.conduitTheme.buttonPrimaryText, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + curve: AnimationCurves.pageTransition, + ); + } + + Widget _buildNoResultsState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search_rounded, + size: IconSize.xxl, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(height: Spacing.lg), + Text( + 'No conversations found', + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + 'Try adjusting your search terms', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + curve: AnimationCurves.pageTransition, + ); + } + + Widget _buildLoadingState() { + return ListView.builder( + padding: const EdgeInsets.all(Spacing.pagePadding), + itemCount: 6, + itemBuilder: (context, index) { + return Container( + margin: const EdgeInsets.only(bottom: Spacing.listGap), + padding: const EdgeInsets.all(Spacing.listItemPadding), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.low, + ), + child: Row( + children: [ + Container( + width: IconSize.avatar, + height: IconSize.avatar, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular(AppBorderRadius.avatar), + ), + ).animate().shimmer(duration: AnimationDuration.slow), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: AppTypography.bodyLarge, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ).animate().shimmer(duration: AnimationDuration.slow), + const SizedBox(height: Spacing.xs), + Container( + height: AppTypography.bodySmall, + width: double.infinity, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ).animate().shimmer(duration: AnimationDuration.slow), + ], + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildErrorState(Object error) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_triangle + : Icons.error_rounded, + size: IconSize.xxl, + color: context.conduitTheme.error, + ), + const SizedBox(height: Spacing.lg), + Text( + 'Failed to load conversations', + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + 'Please try again later', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.xl), + ElevatedButton( + onPressed: () => ref.invalidate(conversationsProvider), + style: ElevatedButton.styleFrom( + backgroundColor: context.conduitTheme.buttonPrimary, + foregroundColor: context.conduitTheme.buttonPrimaryText, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.buttonPadding, + vertical: Spacing.md, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.button), + ), + elevation: Elevation.none, + ), + child: Text( + 'Retry', + style: AppTypography.labelStyle.copyWith( + color: context.conduitTheme.buttonPrimaryText, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + Widget _buildBottomActions() { + return const SizedBox.shrink(); // Remove bottom actions since we'll use FAB + } + + // Helper methods + List _filterConversations(List conversations) { + if (_searchQuery.isEmpty) return conversations; + + return conversations.where((conversation) { + final title = conversation.title?.toLowerCase() ?? ''; + final content = _getConversationPreview(conversation).toLowerCase(); + final query = _searchQuery.toLowerCase(); + + return title.contains(query) || content.contains(query); + }).toList(); + } + + String _getConversationPreview(dynamic conversation) { + if (conversation.messages != null && conversation.messages.isNotEmpty) { + final lastMessage = conversation.messages.last; + return lastMessage.content ?? 'No content'; + } + return 'Start a new conversation'; + } + + String _formatConversationDate(DateTime? date) { + if (date == null) return ''; + + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + // Same day - show time + final hour = date.hour; + final minute = date.minute; + final period = hour >= 12 ? 'PM' : 'AM'; + final displayHour = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour); + return '$displayHour:${minute.toString().padLeft(2, '0')} $period'; + } else if (difference.inDays == 1) { + return 'Yesterday'; + } else if (difference.inDays < 7) { + // Show day name for this week + final days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return days[date.weekday - 1]; + } else if (difference.inDays < 365) { + // Show month and day for this year + final months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${months[date.month - 1]} ${date.day}'; + } else { + // Show full date for older conversations + return '${date.month}/${date.day}/${date.year}'; + } + } + + // TODO: Implement search toggle functionality when needed + // void _toggleSearch() { + // // Focus the search field when search is toggled + // FocusScope.of(context).requestFocus(FocusNode()); + // _searchController.clear(); + // setState(() { + // _searchQuery = ''; + // }); + // ref.read(searchQueryProvider.notifier).state = ''; + // } + + void _showOptions() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.bottomSheet), + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.modal, + ), + child: SafeArea( + top: false, + bottom: true, + child: Padding( + padding: const EdgeInsets.all(Spacing.bottomSheetPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + // Options + ListTile( + leading: Icon( + Platform.isIOS + ? CupertinoIcons.folder + : Icons.folder_rounded, + color: context.conduitTheme.iconPrimary, + ), + title: Text( + 'Manage Folders', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + onTap: () { + Navigator.pop(context); + _showFolderManagement(); + }, + ), + ListTile( + leading: Icon( + Platform.isIOS + ? CupertinoIcons.archivebox + : Icons.archive_rounded, + color: context.conduitTheme.iconPrimary, + ), + title: Text( + 'Archived Chats', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + onTap: () { + Navigator.pop(context); + _showArchivedSection(); + }, + ), + ], + ), + ), + ), + ), + ); + } + + void _selectConversation(dynamic conversation) async { + if (_isLoadingConversation) return; // Prevent multiple loads + + setState(() { + _isLoadingConversation = true; + }); + + try { + // Mark global conversation loading state to show skeletons in chat + ref.read(isLoadingConversationProvider.notifier).state = true; + // Load the full conversation with messages + final api = ref.read(apiServiceProvider); + if (api != null) { + debugPrint('DEBUG: Loading full conversation: ${conversation.id}'); + final fullConversation = await api.getConversation(conversation.id); + debugPrint( + 'DEBUG: Loaded conversation with ${fullConversation.messages.length} messages', + ); + + // Set the full conversation as active + ref.read(activeConversationProvider.notifier).state = fullConversation; + // Clear global loading before navigating so chat doesn't stick on skeletons + ref.read(isLoadingConversationProvider.notifier).state = false; + } else { + // Fallback to the conversation from the list + ref.read(activeConversationProvider.notifier).state = conversation; + // Clear global loading before navigating + ref.read(isLoadingConversationProvider.notifier).state = false; + } + + // Do not navigate synchronously after async awaits; schedule for next frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (widget.isOverlay) { + Navigator.of(context).pop(); + } else { + Navigator.of(context).pop(); + } + }); + } catch (e) { + debugPrint('DEBUG: Error loading conversation: $e'); + // Fallback to the conversation from the list + ref.read(activeConversationProvider.notifier).state = conversation; + // Ensure global loading is cleared even on error + ref.read(isLoadingConversationProvider.notifier).state = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (widget.isOverlay) { + Navigator.of(context).pop(); + } else { + Navigator.of(context).pop(); + } + }); + } finally { + if (mounted) { + setState(() { + _isLoadingConversation = false; + }); + } + } + } + + void _showConversationOptions(dynamic conversation) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.bottomSheet), + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.modal, + ), + child: SafeArea( + top: false, + bottom: true, + child: Padding( + padding: const EdgeInsets.all(Spacing.bottomSheetPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + // Conversation title + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Text( + conversation.title ?? 'New Chat', + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ), + // Options + ListTile( + leading: Icon( + Platform.isIOS ? CupertinoIcons.pin : Icons.push_pin, + color: conversation.pinned == true + ? context.conduitTheme.warning + : context.conduitTheme.iconPrimary, + ), + title: Text( + conversation.pinned == true ? 'Unpin Chat' : 'Pin Chat', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + onTap: () { + Navigator.pop(context); + _togglePinConversation(conversation); + }, + ), + ListTile( + leading: Icon( + Platform.isIOS + ? CupertinoIcons.folder + : Icons.folder_rounded, + color: context.conduitTheme.iconPrimary, + ), + title: Text( + 'Move to Folder', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + onTap: () { + Navigator.pop(context); + _moveToFolder(conversation); + }, + ), + ListTile( + leading: Icon( + Platform.isIOS + ? CupertinoIcons.archivebox + : Icons.archive_rounded, + color: context.conduitTheme.iconPrimary, + ), + title: Text( + 'Archive Chat', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + onTap: () { + Navigator.pop(context); + _archiveConversation(conversation); + }, + ), + ListTile( + leading: Icon( + Platform.isIOS + ? CupertinoIcons.delete + : Icons.delete_rounded, + color: context.conduitTheme.error, + ), + title: Text( + 'Delete Chat', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.error, + ), + ), + onTap: () { + Navigator.pop(context); + _deleteConversation(conversation); + }, + ), + ], + ), + ), + ), + ), + ); + } + + void _startNewChat() { + startNewChat(ref); + if (widget.isOverlay) { + Navigator.of(context).pop(); // Close the overlay + } else { + Navigator.of(context).pop(); // Go back to main navigation + } + } + + void _showFolderManagement() { + showDialog( + context: context, + builder: (context) => const FolderManagementDialog(), + ); + } + + void _togglePinConversation(dynamic conversation) async { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + final newPinnedState = !(conversation.pinned ?? false); + await api.pinConversation(conversation.id, newPinnedState); + + // Refresh conversations list + ref.invalidate(conversationsProvider); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + newPinnedState ? 'Chat pinned' : 'Chat unpinned', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textInverse, + ), + ), + backgroundColor: context.conduitTheme.success, + ), + ); + } + } + } catch (e) { + debugPrint('DEBUG: Error toggling pin: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to ${conversation.pinned == true ? 'unpin' : 'pin'} chat', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textInverse, + ), + ), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + } + + void _moveToFolder(dynamic conversation) { + // TODO: Implement folder selection dialog + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Move to folder feature coming soon!', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textInverse, + ), + ), + backgroundColor: context.conduitTheme.info, + ), + ); + } + } + + void _archiveConversation(dynamic conversation) async { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + await api.archiveConversation(conversation.id, true); + + // Refresh conversations list + ref.invalidate(conversationsProvider); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Chat archived', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textInverse, + ), + ), + backgroundColor: context.conduitTheme.success, + ), + ); + } + } + } catch (e) { + debugPrint('DEBUG: Error archiving conversation: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to archive chat', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textInverse, + ), + ), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + } + + void _deleteConversation(dynamic conversation) async { + // Show confirmation dialog + final confirmed = await ThemedDialogs.confirm( + context, + title: 'Delete Chat', + message: + 'Are you sure you want to delete "${conversation.title ?? 'New Chat'}"? This action cannot be undone.', + confirmText: 'Delete', + isDestructive: true, + barrierDismissible: true, + ); + + if (confirmed == true) { + try { + final api = ref.read(apiServiceProvider); + if (api != null) { + await api.deleteConversation(conversation.id); + + // Refresh conversations list + ref.invalidate(conversationsProvider); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Chat deleted', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textInverse, + ), + ), + backgroundColor: context.conduitTheme.success, + ), + ); + } + } + } catch (e) { + debugPrint('DEBUG: Error deleting conversation: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to delete chat', + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textInverse, + ), + ), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + } + } + + void _showArchivedSection() { + // Set the archived section to be visible + ref.read(_showArchivedProvider.notifier).state = true; + + // Scroll to the archived section + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + } + }); + } + + Widget _buildSectionHeader(String title, int count) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.md, + ), + child: Row( + children: [ + Text( + title, + style: AppTypography.labelStyle.copyWith( + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer, + borderRadius: BorderRadius.circular(AppBorderRadius.badge), + ), + child: Text( + count.toString(), + style: AppTypography.captionStyle.copyWith( + color: context.conduitTheme.textTertiary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + Widget _buildArchivedSection(List archivedConversations) { + return Consumer( + builder: (context, ref, child) { + final showArchived = ref.watch(_showArchivedProvider); + + return Column( + children: [ + // Collapsible header + InkWell( + onTap: () { + ref.read(_showArchivedProvider.notifier).state = !showArchived; + }, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.archivebox + : Icons.archive_rounded, + size: IconSize.small, + color: context.conduitTheme.textSecondary, + ), + const SizedBox(width: Spacing.sm), + Text( + 'Archived', + style: AppTypography.labelStyle.copyWith( + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer, + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + ), + child: Text( + archivedConversations.length.toString(), + style: AppTypography.captionStyle.copyWith( + color: context.conduitTheme.textTertiary, + fontWeight: FontWeight.w600, + ), + ), + ), + const Spacer(), + Icon( + showArchived + ? (Platform.isIOS + ? CupertinoIcons.chevron_up + : Icons.keyboard_arrow_up) + : (Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.keyboard_arrow_down), + size: IconSize.small, + color: context.conduitTheme.textSecondary, + ), + ], + ), + ), + ), + + // Archived conversations (collapsible) + if (showArchived) ...[ + const SizedBox(height: Spacing.sm), + ...archivedConversations.asMap().entries.map((entry) { + return _buildConversationTile( + entry.value, + entry.key, + isArchived: true, + ); + }), + ], + ], + ); + }, + ); + } +} diff --git a/lib/features/navigation/views/splash_launcher_page.dart b/lib/features/navigation/views/splash_launcher_page.dart new file mode 100644 index 0000000..9a29ceb --- /dev/null +++ b/lib/features/navigation/views/splash_launcher_page.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; + +class SplashLauncherPage extends StatelessWidget { + const SplashLauncherPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + body: Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.loadingIndicator, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/onboarding/views/onboarding_sheet.dart b/lib/features/onboarding/views/onboarding_sheet.dart new file mode 100644 index 0000000..700dc2b --- /dev/null +++ b/lib/features/onboarding/views/onboarding_sheet.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +class OnboardingSheet extends StatefulWidget { + const OnboardingSheet({super.key}); + + @override + State createState() => _OnboardingSheetState(); +} + +class _OnboardingSheetState extends State { + final PageController _controller = PageController(); + int _index = 0; + + final List<_OnboardingPage> _pages = const [ + _OnboardingPage( + title: 'Start a conversation', + subtitle: + 'Choose a model, then type below to begin. Tap New Chat anytime.', + icon: CupertinoIcons.chat_bubble_2, + bullets: [ + 'Tap the model name in the top bar to switch models', + 'Use New Chat to reset context', + ], + ), + _OnboardingPage( + title: 'Attach context', + subtitle: 'Ground responses by adding files or images.', + icon: CupertinoIcons.doc_on_doc, + bullets: ['Files: PDFs, docs, datasets', 'Images: photos or screenshots'], + ), + _OnboardingPage( + title: 'Speak naturally', + subtitle: 'Tap the mic to dictate with live waveform feedback.', + icon: CupertinoIcons.mic_fill, + bullets: [ + 'Stop anytime; partial text is preserved', + 'Great for quick notes or long prompts', + ], + ), + ]; + + void _next() { + if (_index < _pages.length - 1) { + _controller.nextPage( + duration: AnimationDuration.fast, + curve: AnimationCurves.easeInOut, + ); + } else { + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + final height = MediaQuery.of(context).size.height; + final isSmall = height < 720; + return Container( + height: height * 0.7, + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.modal), + ), + boxShadow: ConduitShadows.modal, + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ), + + Expanded( + child: PageView.builder( + controller: _controller, + itemCount: _pages.length, + onPageChanged: (i) => setState(() => _index = i), + itemBuilder: (context, i) { + final page = _pages[i]; + final content = _IllustratedPage(page: page); + return isSmall + ? SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + ), + child: content, + ) + : content; + }, + ), + ), + + const SizedBox(height: Spacing.md), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_pages.length, (i) { + final active = i == _index; + return AnimatedContainer( + duration: AnimationDuration.fast, + margin: const EdgeInsets.symmetric(horizontal: 4), + height: 6, + width: active ? 20 : 6, + decoration: BoxDecoration( + color: active + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + ), + ); + }), + ), + + const SizedBox(height: Spacing.lg), + Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Skip', + style: TextStyle( + color: context.conduitTheme.textSecondary, + ), + ), + ), + const Spacer(), + FilledButton( + onPressed: _next, + style: FilledButton.styleFrom( + backgroundColor: context.conduitTheme.buttonPrimary, + foregroundColor: context.conduitTheme.buttonPrimaryText, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + vertical: Spacing.sm, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.button, + ), + ), + ), + child: Text(_index == _pages.length - 1 ? 'Done' : 'Next'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _OnboardingPage { + final String title; + final String subtitle; + final IconData icon; + final List? bullets; + const _OnboardingPage({ + required this.title, + required this.subtitle, + required this.icon, + this.bullets, + }); +} + +class _IllustratedPage extends StatelessWidget { + final _OnboardingPage page; + const _IllustratedPage({required this.page}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Aurora blob illustration + SizedBox( + height: 160, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned(top: 10, left: 24, child: _blob(context, 90, 0.18)), + Positioned( + bottom: 0, + right: 16, + child: _blob(context, 130, 0.12), + ), + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + borderRadius: BorderRadius.circular(AppBorderRadius.avatar), + boxShadow: ConduitShadows.glow, + ), + child: Icon(page.icon, color: context.conduitTheme.textInverse), + ).animate().scale(duration: AnimationDuration.fast), + ], + ), + ), + const SizedBox(height: Spacing.lg), + Text( + page.title, + style: TextStyle( + fontSize: AppTypography.headlineMedium, + fontWeight: FontWeight.w700, + color: context.conduitTheme.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.sm), + Text( + page.subtitle, + style: TextStyle( + fontSize: AppTypography.bodyLarge, + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + if (page.bullets != null && page.bullets!.isNotEmpty) ...[ + const SizedBox(height: Spacing.md), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: page.bullets! + .map( + (b) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + vertical: 4, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(top: 8, right: 8), + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Text( + b, + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodyMedium, + ), + ), + ), + ], + ), + ), + ) + .toList(), + ), + ], + ], + ); + } + + Widget _blob(BuildContext context, double size, double alpha) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.conduitTheme.buttonPrimary.withValues(alpha: alpha), + boxShadow: ConduitShadows.glow, + ), + ); + } +} diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart new file mode 100644 index 0000000..0b5e933 --- /dev/null +++ b/lib/features/profile/views/profile_page.dart @@ -0,0 +1,468 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/widgets/improved_loading_states.dart'; + +import '../../../shared/utils/ui_utils.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../auth/providers/unified_auth_providers.dart'; + +/// Profile page (You tab) showing user info and main actions +/// Enhanced with production-grade design tokens for better cohesion +class ProfilePage extends ConsumerWidget { + const ProfilePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(currentUserProvider); + + return ErrorBoundary( + child: user.when( + data: (userData) => Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: Elevation.none, + automaticallyImplyLeading: false, + toolbarHeight: kToolbarHeight, + titleSpacing: 0.0, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'You', + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + centerTitle: true, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(Spacing.pagePadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Profile Header - Enhanced with better spacing and animations + _buildProfileHeader(userData) + .animate() + .fadeIn(duration: AnimationDuration.pageTransition) + .slideY( + begin: 0.1, + end: 0, + curve: AnimationCurves.pageTransition, + ), + const SizedBox(height: Spacing.sectionGap), + + // Account Section - Enhanced with improved spacing + _buildAccountSection(context, ref) + .animate() + .fadeIn( + delay: AnimationDelay.short, + duration: AnimationDuration.pageTransition, + ) + .slideY( + begin: 0.1, + end: 0, + curve: AnimationCurves.pageTransition, + ), + ], + ), + ), + ), + loading: () => Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: Elevation.none, + automaticallyImplyLeading: false, + title: Text( + 'You', + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + ), + body: const Center( + child: ImprovedLoadingState(message: 'Loading profile...'), + ), + ), + error: (error, stack) => Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: Elevation.none, + automaticallyImplyLeading: false, + title: Text( + 'You', + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + ), + body: Center( + child: ImprovedEmptyState( + title: 'Unable to load profile', + subtitle: 'Please check your connection and try again', + icon: UiUtils.platformIcon( + ios: CupertinoIcons.exclamationmark_triangle, + android: Icons.error_outline, + ), + ), + ), + ), + ), + ); + } + + Widget _buildProfileHeader(dynamic user) { + return Builder( + builder: (context) => ConduitCard( + padding: const EdgeInsets.all(Spacing.cardPadding), + child: Row( + children: [ + // Enhanced avatar with better sizing and shadows + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppBorderRadius.avatar), + boxShadow: ConduitShadows.card, + ), + child: ConduitAvatar( + size: IconSize.avatar, + text: user?.name?.substring(0, 1) ?? 'U', + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user?.name ?? 'User', + style: context.conduitTheme.headingMedium?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.xs), + Text( + user?.email ?? 'No email', + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + const SizedBox(height: Spacing.sm), + // Enhanced status badge with better styling + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.success.withValues( + alpha: Alpha.badgeBackground, + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + border: Border.all( + color: context.conduitTheme.success.withValues( + alpha: Alpha.avatarBorder, + ), + width: BorderWidth.thin, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: context.conduitTheme.success, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: Spacing.xs), + Text( + 'Active', + style: context.conduitTheme.label?.copyWith( + color: context.conduitTheme.success, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildAccountSection(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Account', + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + const SizedBox(height: Spacing.md), + ConduitCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _buildThemeToggleTile(context, ref), + Divider(color: context.conduitTheme.dividerColor, height: 1), + _buildAboutTile(context), + Divider(color: context.conduitTheme.dividerColor, height: 1), + _buildAccountOption( + icon: UiUtils.platformIcon( + ios: CupertinoIcons.square_arrow_left, + android: Icons.logout, + ), + title: 'Sign Out', + subtitle: 'End your session', + onTap: () => _signOut(context, ref), + isDestructive: true, + ), + ], + ), + ), + ], + ); + } + + Widget _buildAccountOption({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + bool isDestructive = false, + }) { + return Builder( + builder: (context) => ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.listItemPadding, + vertical: Spacing.sm, + ), + leading: Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: isDestructive + ? context.conduitTheme.error.withValues(alpha: Alpha.highlight) + : context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.highlight, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.small), + ), + child: Icon( + icon, + color: isDestructive + ? context.conduitTheme.error + : context.conduitTheme.buttonPrimary, + size: IconSize.medium, + ), + ), + title: Text( + title, + style: context.conduitTheme.bodyLarge?.copyWith( + color: isDestructive + ? context.conduitTheme.error + : context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + subtitle, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + trailing: Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.chevron_right, + android: Icons.chevron_right, + ), + color: context.conduitTheme.iconSecondary, + size: IconSize.small, + ), + onTap: onTap, + ), + ); + } + + Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeModeProvider); + final isDark = themeMode == ThemeMode.dark; + + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.listItemPadding, + vertical: Spacing.sm, + ), + leading: Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.highlight, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.small), + ), + child: Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.moon_stars, + android: Icons.dark_mode, + ), + color: context.conduitTheme.buttonPrimary, + size: IconSize.medium, + ), + ), + title: Text( + 'Dark Mode', + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + isDark ? 'Currently using Dark theme' : 'Currently using Light theme', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + trailing: Switch.adaptive( + value: isDark, + onChanged: (value) { + ref + .read(themeModeProvider.notifier) + .setTheme(value ? ThemeMode.dark : ThemeMode.light); + }, + ), + onTap: () { + final newValue = !isDark; + ref + .read(themeModeProvider.notifier) + .setTheme(newValue ? ThemeMode.dark : ThemeMode.light); + }, + ); + } + + Widget _buildAboutTile(BuildContext context) { + return _buildAccountOption( + icon: UiUtils.platformIcon( + ios: CupertinoIcons.info, + android: Icons.info_outline, + ), + title: 'About App', + subtitle: 'Conduit information and links', + onTap: () => _showAboutDialog(context), + ); + } + + Future _showAboutDialog(BuildContext context) async { + try { + final info = await PackageInfo.fromPlatform(); + // Update dialog with dynamic version each time + // GitHub repo URL source of truth + const githubUrl = 'https://github.com/cogwheel0/conduit'; + + if (!context.mounted) return; + await showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + backgroundColor: ctx.conduitTheme.surfaceBackground, + title: Text( + 'About Conduit', + style: ctx.conduitTheme.headingSmall?.copyWith( + color: ctx.conduitTheme.textPrimary, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Version: ${info.version} (${info.buildNumber})', + style: ctx.conduitTheme.bodyMedium?.copyWith( + color: ctx.conduitTheme.textSecondary, + ), + ), + const SizedBox(height: Spacing.md), + InkWell( + onTap: () => launchUrlString( + githubUrl, + mode: LaunchMode.externalApplication, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.link, + android: Icons.link, + ), + size: IconSize.small, + color: ctx.conduitTheme.buttonPrimary, + ), + const SizedBox(width: Spacing.xs), + Text( + 'GitHub Repository', + style: ctx.conduitTheme.bodyMedium?.copyWith( + color: ctx.conduitTheme.buttonPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } catch (e) { + if (!context.mounted) return; + UiUtils.showMessage(context, 'Unable to load app info'); + } + } + + void _signOut(BuildContext context, WidgetRef ref) async { + final confirm = await UiUtils.showConfirmationDialog( + context, + title: 'Sign out?', + message: 'You\'ll need to sign in again to continue', + confirmText: 'Sign out', + isDestructive: true, + ); + + if (confirm) { + await ref.read(logoutActionProvider); + } + } +} diff --git a/lib/features/server/providers/server_providers.dart b/lib/features/server/providers/server_providers.dart new file mode 100644 index 0000000..ce59488 --- /dev/null +++ b/lib/features/server/providers/server_providers.dart @@ -0,0 +1,56 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/models/server_config.dart'; +import '../../../core/providers/app_providers.dart'; + +// Server management providers +final addServerProvider = FutureProvider.family(( + ref, + server, +) async { + final storage = ref.read(optimizedStorageServiceProvider); + final configs = await storage.getServerConfigs(); + + // Add new server + configs.add(server); + + // Save updated list + await storage.saveServerConfigs(configs); + + // Refresh the server list + ref.invalidate(serverConfigsProvider); +}); + +final deleteServerProvider = FutureProvider.family(( + ref, + serverId, +) async { + final storage = ref.read(optimizedStorageServiceProvider); + final configs = await storage.getServerConfigs(); + + // Remove server with matching ID + configs.removeWhere((config) => config.id == serverId); + + // Save updated list + await storage.saveServerConfigs(configs); + + // If this was the active server, clear active server ID + final activeId = await storage.getActiveServerId(); + if (activeId == serverId) { + await storage.setActiveServerId(null); + } + + // Refresh providers + ref.invalidate(serverConfigsProvider); + ref.invalidate(activeServerProvider); +}); + +final setActiveServerProvider = FutureProvider.family(( + ref, + serverId, +) async { + final storage = ref.read(optimizedStorageServiceProvider); + await storage.setActiveServerId(serverId); + + // Refresh active server provider + ref.invalidate(activeServerProvider); +}); diff --git a/lib/features/settings/views/accessibility_settings_page.dart b/lib/features/settings/views/accessibility_settings_page.dart new file mode 100644 index 0000000..686c860 --- /dev/null +++ b/lib/features/settings/views/accessibility_settings_page.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../shared/widgets/conduit_components.dart'; +import '../../../core/services/settings_service.dart'; +import '../../../core/services/enhanced_accessibility_service.dart'; +import '../../../core/services/platform_service.dart'; + +/// Accessibility settings page with WCAG 2.2 AA compliance controls +class AccessibilitySettingsPage extends ConsumerWidget { + const AccessibilitySettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(appSettingsProvider); + + return Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: PlatformService.createPlatformAppBar( + title: 'Accessibility', + backgroundColor: context.conduitTheme.surfaceBackground, + foregroundColor: context.conduitTheme.textPrimary, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, 'Motion & Animation'), + const SizedBox(height: Spacing.sm), + + // Reduce Motion Toggle + ConduitCard( + child: EnhancedAccessibilityService.createAccessibleSwitch( + value: settings.reduceMotion, + onChanged: (value) { + ref.read(appSettingsProvider.notifier).setReduceMotion(value); + EnhancedAccessibilityService.announceSuccess( + value + ? 'Reduced motion enabled' + : 'Reduced motion disabled', + ); + }, + label: 'Reduce Motion', + description: + 'Minimize animations and transitions for better focus and reduced vestibular disturbance', + ), + ), + + const SizedBox(height: Spacing.sm), + + // Animation Speed Slider + if (!settings.reduceMotion) ...[ + ConduitCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Animation Speed', + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + 'Adjust the speed of animations and transitions', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.labelLarge, + ), + ), + const SizedBox(height: Spacing.md), + EnhancedAccessibilityService.createAccessibleSlider( + value: settings.animationSpeed, + onChanged: (value) { + ref + .read(appSettingsProvider.notifier) + .setAnimationSpeed(value); + }, + label: 'Animation speed', + min: 0.5, + max: 2.0, + divisions: 6, + valueFormatter: (value) { + if (value < 0.75) return 'Slow'; + if (value < 1.25) return 'Normal'; + return 'Fast'; + }, + ), + ], + ), + ), + const SizedBox(height: Spacing.sm), + ], + + const SizedBox(height: Spacing.lg), + _buildSectionHeader(context, 'Visual & Text'), + const SizedBox(height: Spacing.sm), + + // Large Text Toggle + ConduitCard( + child: EnhancedAccessibilityService.createAccessibleSwitch( + value: settings.largeText, + onChanged: (value) { + ref.read(appSettingsProvider.notifier).setLargeText(value); + EnhancedAccessibilityService.announceSuccess( + value ? 'Large text enabled' : 'Large text disabled', + ); + }, + label: 'Large Text', + description: + 'Increase text size throughout the app for better readability', + ), + ), + + const SizedBox(height: Spacing.sm), + + // High Contrast Toggle + ConduitCard( + child: EnhancedAccessibilityService.createAccessibleSwitch( + value: settings.highContrast, + onChanged: (value) { + ref.read(appSettingsProvider.notifier).setHighContrast(value); + EnhancedAccessibilityService.announceSuccess( + value ? 'High contrast enabled' : 'High contrast disabled', + ); + }, + label: 'High Contrast', + description: + 'Increase contrast between text and background colors', + ), + ), + + const SizedBox(height: Spacing.lg), + _buildSectionHeader(context, 'Interaction'), + const SizedBox(height: Spacing.sm), + + // Haptic Feedback Toggle + ConduitCard( + child: EnhancedAccessibilityService.createAccessibleSwitch( + value: settings.hapticFeedback, + onChanged: (value) { + ref + .read(appSettingsProvider.notifier) + .setHapticFeedback(value); + if (value) { + PlatformService.hapticFeedback(type: HapticType.success); + } + EnhancedAccessibilityService.announceSuccess( + value + ? 'Haptic feedback enabled' + : 'Haptic feedback disabled', + ); + }, + label: 'Haptic Feedback', + description: + 'Feel vibrations when interacting with buttons and controls', + ), + ), + + const SizedBox(height: Spacing.lg), + _buildSectionHeader(context, 'System Integration'), + const SizedBox(height: Spacing.sm), + + // System Settings Info Card + ConduitCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: context.conduitTheme.buttonPrimary, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Text( + 'System Settings', + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: Spacing.sm), + Text( + 'Conduit automatically respects your device\'s accessibility settings, including:', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.labelLarge, + ), + ), + const SizedBox(height: Spacing.sm), + ...[ + '• Reduce Motion (iOS/Android)', + '• VoiceOver/TalkBack screen readers', + '• Dynamic Type/Font scale', + '• Color inversion and filters', + ].map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + item, + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.labelLarge, + ), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: Spacing.lg), + + // Reset to Defaults Button + ConduitButton( + text: 'Reset to Defaults', + onPressed: () => _showResetDialog(context, ref), + isSecondary: true, + width: double.infinity, + ), + + const SizedBox(height: Spacing.xl), + ], + ), + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return EnhancedAccessibilityService.createAccessibleText( + title, + style: TextStyle( + color: context.conduitTheme.buttonPrimary, + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + ), + isHeader: true, + ); + } + + Future _showResetDialog(BuildContext context, WidgetRef ref) async { + final confirmed = await PlatformService.showPlatformAlert( + context: context, + title: 'Reset Accessibility Settings', + content: + 'This will reset all accessibility preferences to their default values. Are you sure?', + confirmText: 'Reset', + cancelText: 'Cancel', + isDestructive: true, + ); + + if (confirmed == true) { + await ref.read(appSettingsProvider.notifier).resetToDefaults(); + EnhancedAccessibilityService.announceSuccess( + 'Accessibility settings reset to defaults', + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Accessibility settings reset to defaults'), + backgroundColor: context.conduitTheme.buttonPrimary, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } +} diff --git a/lib/features/settings/views/searchable_settings_page.dart b/lib/features/settings/views/searchable_settings_page.dart new file mode 100644 index 0000000..6f545cc --- /dev/null +++ b/lib/features/settings/views/searchable_settings_page.dart @@ -0,0 +1,810 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:io' show Platform; +import '../../../core/widgets/error_boundary.dart'; +import '../../../core/services/navigation_service.dart'; +import '../../../shared/widgets/themed_dialogs.dart'; +import '../../../core/services/focus_management_service.dart'; +import '../../../shared/widgets/improved_loading_states.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import '../../../core/models/user_settings.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../shared/utils/platform_utils.dart'; + +enum ThemeVariant { conduit } + +// Settings search provider +final settingsSearchQueryProvider = StateProvider((ref) => ''); + +// Setting item model +class SettingItem { + final String id; + final String title; + final String? subtitle; + final IconData icon; + final String category; + final List searchTerms; + final VoidCallback? onTap; + final Widget? trailing; + + SettingItem({ + required this.id, + required this.title, + this.subtitle, + required this.icon, + required this.category, + required this.searchTerms, + this.onTap, + this.trailing, + }); + + bool matchesSearch(String query) { + final lowerQuery = query.toLowerCase(); + return title.toLowerCase().contains(lowerQuery) || + (subtitle?.toLowerCase().contains(lowerQuery) ?? false) || + category.toLowerCase().contains(lowerQuery) || + searchTerms.any((term) => term.toLowerCase().contains(lowerQuery)); + } +} + +class SearchableSettingsPage extends ConsumerStatefulWidget { + const SearchableSettingsPage({super.key}); + + @override + ConsumerState createState() => + _SearchableSettingsPageState(); +} + +class _SearchableSettingsPageState + extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + late FocusNode _searchFocusNode; + bool _isSearching = false; + + @override + void initState() { + super.initState(); + _searchFocusNode = FocusManagementService.registerFocusNode( + 'settings_search', + debugLabel: 'Settings Search Field', + ); + } + + @override + void dispose() { + _searchController.dispose(); + FocusManagementService.disposeFocusNode('settings_search'); + super.dispose(); + } + + List _buildSettingItems(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeModeProvider); + // Single Conduit theme variant in this refactor; kept provider for future use + final userSettingsAsync = ref.watch(userSettingsProvider); + final userSettings = userSettingsAsync.when( + data: (data) => data, + loading: () => null, + error: (_, _) => null, + ); + + return [ + // Profile & Account + SettingItem( + id: 'profile', + title: 'Profile', + subtitle: 'Manage your account details', + icon: Platform.isIOS + ? CupertinoIcons.person_circle + : Icons.account_circle, + category: 'Profile & Account', + searchTerms: ['account', 'user', 'name', 'email', 'avatar'], + onTap: () => _navigateToProfile(context), + ), + SettingItem( + id: 'server', + title: 'Server Connection', + subtitle: 'Manage Open WebUI servers', + icon: Platform.isIOS ? CupertinoIcons.cloud : Icons.cloud, + category: 'Profile & Account', + searchTerms: ['server', 'connection', 'api', 'host', 'url'], + onTap: () => _navigateToServerSettings(context), + ), + SettingItem( + id: 'sign-out', + title: 'Sign Out', + subtitle: 'Sign out of your account', + icon: Platform.isIOS ? CupertinoIcons.square_arrow_right : Icons.logout, + category: 'Profile & Account', + searchTerms: ['logout', 'signout', 'exit'], + onTap: () => _handleSignOut(context, ref), + ), + + // Appearance + SettingItem( + id: 'theme', + title: 'Theme', + subtitle: 'Choose light or dark theme', + icon: Platform.isIOS ? CupertinoIcons.moon_circle : Icons.dark_mode, + category: 'Appearance', + searchTerms: ['dark', 'light', 'mode', 'appearance', 'color'], + trailing: _buildThemeSelector(ref, themeMode), + ), + // Removed variant switching; Conduit brand theme is the single source of truth + SettingItem( + id: 'text-size', + title: 'Text Size', + subtitle: 'Adjust font size for better readability', + icon: Platform.isIOS + ? CupertinoIcons.textformat_size + : Icons.text_fields, + category: 'Appearance', + searchTerms: ['font', 'size', 'text', 'readability', 'accessibility'], + onTap: () => _showTextSizeDialog(context), + ), + + // Chat & AI + SettingItem( + id: 'stream-responses', + title: 'Stream Responses', + subtitle: 'See responses as they\'re generated', + icon: Platform.isIOS ? CupertinoIcons.bolt : Icons.flash_on, + category: 'Chat & AI', + searchTerms: ['stream', 'real-time', 'live', 'responses'], + trailing: PlatformUtils.createSwitch( + value: userSettings?.streamResponses ?? true, + onChanged: (value) => _updateSetting(ref, 'streamResponses', value), + ), + ), + SettingItem( + id: 'save-conversations', + title: 'Save Conversations', + subtitle: 'Keep chat history between sessions', + icon: Platform.isIOS ? CupertinoIcons.archivebox : Icons.save, + category: 'Chat & AI', + searchTerms: ['save', 'history', 'conversations', 'chat', 'archive'], + trailing: PlatformUtils.createSwitch( + value: userSettings?.saveConversations ?? true, + onChanged: (value) => _updateSetting(ref, 'saveConversations', value), + ), + ), + SettingItem( + id: 'web-search', + title: 'Web Search', + subtitle: 'Allow AI to search the web for information', + icon: Platform.isIOS ? CupertinoIcons.globe : Icons.public, + category: 'Chat & AI', + searchTerms: ['web', 'search', 'internet', 'browse', 'online'], + trailing: Consumer( + builder: (context, ref, child) { + final settings = ref.watch(userSettingsProvider); + return settings.when( + data: (userSettings) => PlatformUtils.createSwitch( + value: userSettings.webSearchEnabled, + onChanged: (value) => + _updateSetting(ref, 'webSearchEnabled', value), + ), + loading: () => + const ImprovedLoadingState(message: 'Loading setting...'), + error: (error, stackTrace) => PlatformUtils.createSwitch( + value: false, + onChanged: (value) => + _updateSetting(ref, 'webSearchEnabled', value), + ), + ); + }, + ), + ), + SettingItem( + id: 'model-selection', + title: 'Default Model', + subtitle: 'Choose your preferred AI model', + icon: Platform.isIOS ? CupertinoIcons.cube : Icons.psychology, + category: 'Chat & AI', + searchTerms: ['model', 'ai', 'gpt', 'conduit', 'llm'], + onTap: () => _showModelSelector(context), + ), + + // Privacy & Security + SettingItem( + id: 'clear-history', + title: 'Clear Chat History', + subtitle: 'Delete all conversations', + icon: Platform.isIOS ? CupertinoIcons.trash : Icons.delete_outline, + category: 'Privacy & Security', + searchTerms: ['clear', 'delete', 'history', 'privacy', 'remove'], + onTap: () => _showClearHistoryDialog(context, ref), + ), + SettingItem( + id: 'export-data', + title: 'Export Data', + subtitle: 'Download your conversations', + icon: Platform.isIOS + ? CupertinoIcons.square_arrow_down + : Icons.download, + category: 'Privacy & Security', + searchTerms: ['export', 'download', 'backup', 'data'], + onTap: () => _handleExportData(context), + ), + + // Accessibility + SettingItem( + id: 'reduce-motion', + title: 'Reduce Motion', + subtitle: 'Minimize animations', + icon: Platform.isIOS ? CupertinoIcons.slowmo : Icons.animation, + category: 'Accessibility', + searchTerms: ['motion', 'animation', 'reduce', 'accessibility'], + trailing: Consumer( + builder: (context, ref, child) { + final settings = ref.watch(userSettingsProvider); + return settings.when( + data: (userSettings) => PlatformUtils.createSwitch( + value: userSettings.reduceMotion, + onChanged: (value) => + _updateSetting(ref, 'reduceMotion', value), + ), + loading: () => + const ImprovedLoadingState(message: 'Loading setting...'), + error: (error, stackTrace) => PlatformUtils.createSwitch( + value: false, + onChanged: (value) => + _updateSetting(ref, 'reduceMotion', value), + ), + ); + }, + ), + ), + SettingItem( + id: 'haptic-feedback', + title: 'Haptic Feedback', + subtitle: 'Vibration feedback for actions', + icon: Platform.isIOS ? CupertinoIcons.hand_draw : Icons.vibration, + category: 'Accessibility', + searchTerms: ['haptic', 'vibration', 'feedback', 'touch'], + trailing: Consumer( + builder: (context, ref, child) { + final settings = ref.watch(userSettingsProvider); + return settings.when( + data: (userSettings) => PlatformUtils.createSwitch( + value: userSettings.hapticFeedback, + onChanged: (value) => + _updateSetting(ref, 'hapticFeedback', value), + ), + loading: () => + const ImprovedLoadingState(message: 'Loading setting...'), + error: (error, stackTrace) => PlatformUtils.createSwitch( + value: true, + onChanged: (value) => + _updateSetting(ref, 'hapticFeedback', value), + ), + ); + }, + ), + ), + + // About + SettingItem( + id: 'version', + title: 'App Version', + subtitle: 'Conduit v1.0.0', + icon: Platform.isIOS ? CupertinoIcons.info_circle : Icons.info_outline, + category: 'About', + searchTerms: ['version', 'about', 'info', 'conduit'], + onTap: () => _showAboutDialog(context), + ), + SettingItem( + id: 'help', + title: 'Help & Support', + subtitle: 'Get assistance and report issues', + icon: Platform.isIOS + ? CupertinoIcons.question_circle + : Icons.help_outline, + category: 'About', + searchTerms: ['help', 'support', 'assistance', 'contact'], + onTap: () => _navigateToHelp(context), + ), + ]; + } + + List _getFilteredSettings(BuildContext context, WidgetRef ref) { + final searchQuery = ref.watch(settingsSearchQueryProvider); + final allSettings = _buildSettingItems(context, ref); + + if (searchQuery.isEmpty) { + return allSettings; + } + + return allSettings + .where((item) => item.matchesSearch(searchQuery)) + .toList(); + } + + Map> _groupSettingsByCategory( + List settings, + ) { + final grouped = >{}; + + for (final setting in settings) { + grouped.putIfAbsent(setting.category, () => []).add(setting); + } + + return grouped; + } + + @override + Widget build(BuildContext context) { + final filteredSettings = _getFilteredSettings(context, ref); + final groupedSettings = _groupSettingsByCategory(filteredSettings); + final categories = groupedSettings.keys.toList()..sort(); + + return ErrorBoundary( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: Elevation.none, + title: _isSearching + ? _buildSearchBar() + : Text( + 'Settings', + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.headlineMedium, + fontWeight: FontWeight.w600, + ), + ), + leading: ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back, + onPressed: () { + if (_isSearching) { + setState(() { + _isSearching = false; + _searchController.clear(); + ref.read(settingsSearchQueryProvider.notifier).state = ''; + }); + } else { + NavigationService.goBack(); + } + }, + ), + actions: [ + if (!_isSearching) + ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.search : Icons.search, + onPressed: () { + setState(() { + _isSearching = true; + }); + _searchFocusNode.requestFocus(); + }, + ), + const SizedBox(width: Spacing.sm), + ], + ), + body: SafeArea( + top: false, + child: filteredSettings.isEmpty + ? _buildEmptySearchResults() + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: categories.length, + itemBuilder: (context, index) { + final category = categories[index]; + final items = groupedSettings[category]!; + + return _buildCategorySection(category, items); + }, + ), + ), + ), // Added closing parenthesis for ErrorBoundary + ); + } + + Widget _buildSearchBar() { + return TextField( + controller: _searchController, + focusNode: _searchFocusNode, + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + ), + decoration: InputDecoration( + hintText: 'Search settings...', + hintStyle: TextStyle( + color: context.conduitTheme.inputPlaceholder, + fontSize: AppTypography.bodyLarge, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + onChanged: (value) { + ref.read(settingsSearchQueryProvider.notifier).state = value; + }, + ); + } + + Widget _buildEmptySearchResults() { + return ImprovedEmptyState( + title: 'No settings found', + subtitle: 'Try a different search term', + icon: Platform.isIOS ? CupertinoIcons.search : Icons.search_off, + showAnimation: true, + ); + } + + Widget _buildCategorySection(String category, List items) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + Spacing.md, + Spacing.md, + Spacing.md, + Spacing.sm, + ), + child: Text( + category, + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: 1, + ), + ), + child: Column( + children: items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isLast = index == items.length - 1; + + return Column( + children: [ + _buildSettingTile(item), + if (!isLast) + Divider( + height: 1, + color: context.conduitTheme.dividerColor, + indent: 56, + ), + ], + ); + }).toList(), + ), + ), + ], + ); + } + + Widget _buildSettingTile(SettingItem item) { + final searchQuery = ref.watch(settingsSearchQueryProvider); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: item.onTap, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Icon( + item.icon, + color: context.conduitTheme.iconSecondary, + size: IconSize.md, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _highlightSearchText(item.title, searchQuery), + if (item.subtitle != null) ...[ + const SizedBox(height: Spacing.xxs), + _highlightSearchText( + item.subtitle!, + searchQuery, + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + ), + ), + ], + ], + ), + ), + if (item.trailing != null) ...[ + const SizedBox(width: Spacing.sm), + item.trailing!, + ] else if (item.onTap != null) + Icon( + Platform.isIOS + ? CupertinoIcons.chevron_forward + : Icons.chevron_right, + color: context.conduitTheme.iconSecondary, + size: IconSize.md, + ), + ], + ), + ), + ), + ); + } + + Widget _highlightSearchText(String text, String query, {TextStyle? style}) { + if (query.isEmpty) { + return Text( + text, + style: + style ?? + TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w500, + ), + ); + } + + final lowerText = text.toLowerCase(); + final lowerQuery = query.toLowerCase(); + final index = lowerText.indexOf(lowerQuery); + + if (index == -1) { + return Text(text, style: style); + } + + final before = text.substring(0, index); + final match = text.substring(index, index + query.length); + final after = text.substring(index + query.length); + + return RichText( + text: TextSpan( + style: + style ?? + TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w500, + ), + children: [ + TextSpan(text: before), + TextSpan( + text: match, + style: TextStyle( + backgroundColor: context.conduitTheme.buttonPrimary.withValues( + alpha: 0.3, + ), + fontWeight: FontWeight.w600, + ), + ), + TextSpan(text: after), + ], + ), + ); + } + + Widget _buildThemeSelector(WidgetRef ref, ThemeMode themeMode) { + return CupertinoSlidingSegmentedControl( + groupValue: themeMode, + children: const { + ThemeMode.light: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Light', + style: TextStyle(fontSize: AppTypography.bodySmall), + ), + ), + ThemeMode.dark: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Dark', + style: TextStyle(fontSize: AppTypography.bodySmall), + ), + ), + ThemeMode.system: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Auto', + style: TextStyle(fontSize: AppTypography.bodySmall), + ), + ), + }, + onValueChanged: (value) { + if (value != null) { + ref.read(themeModeProvider.notifier).setTheme(value); + } + }, + ); + } + + // Theme variant state removed; single Conduit theme in use + + void _updateSetting(WidgetRef ref, String key, dynamic value) async { + try { + final currentSettings = await ref.read(userSettingsProvider.future); + + // Create updated settings based on the key + UserSettings updatedSettings; + switch (key) { + case 'webSearchEnabled': + updatedSettings = currentSettings.copyWith( + webSearchEnabled: value as bool, + ); + break; + case 'reduceMotion': + updatedSettings = currentSettings.copyWith( + reduceMotion: value as bool, + ); + break; + case 'hapticFeedback': + updatedSettings = currentSettings.copyWith( + hapticFeedback: value as bool, + ); + break; + case 'streamResponses': + updatedSettings = currentSettings.copyWith( + streamResponses: value as bool, + ); + break; + case 'saveConversations': + updatedSettings = currentSettings.copyWith( + saveConversations: value as bool, + ); + break; + case 'showReadReceipts': + updatedSettings = currentSettings.copyWith( + showReadReceipts: value as bool, + ); + break; + case 'enableNotifications': + updatedSettings = currentSettings.copyWith( + enableNotifications: value as bool, + ); + break; + case 'enableSounds': + updatedSettings = currentSettings.copyWith( + enableSounds: value as bool, + ); + break; + case 'shareUsageData': + updatedSettings = currentSettings.copyWith( + shareUsageData: value as bool, + ); + break; + case 'temperature': + updatedSettings = currentSettings.copyWith( + temperature: value as double, + ); + break; + case 'maxTokens': + updatedSettings = currentSettings.copyWith(maxTokens: value as int); + break; + case 'fontSize': + updatedSettings = currentSettings.copyWith(fontSize: value as double); + break; + case 'theme': + updatedSettings = currentSettings.copyWith(theme: value as String); + break; + case 'density': + updatedSettings = currentSettings.copyWith(density: value as String); + break; + case 'language': + updatedSettings = currentSettings.copyWith(language: value as String); + break; + default: + // Handle custom settings + final customSettings = Map.from( + currentSettings.customSettings, + ); + customSettings[key] = value; + updatedSettings = currentSettings.copyWith( + customSettings: customSettings, + ); + } + + // Update settings on server + final api = ref.read(apiServiceProvider); + if (api != null) { + await api.updateUserSettings(updatedSettings.toJson()); + + // Invalidate the provider to refresh the UI + ref.invalidate(userSettingsProvider); + + // Show success message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Setting updated'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } catch (e) { + // Show error message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update setting: $e'), + backgroundColor: context.conduitTheme.error, + ), + ); + } + } + } + + void _navigateToProfile(BuildContext context) { + // TODO: Navigate to profile page + } + + void _navigateToServerSettings(BuildContext context) { + NavigationService.navigateTo('/server-connection'); + } + + void _handleSignOut(BuildContext context, WidgetRef ref) { + // ignore: unawaited_futures + ThemedDialogs.confirm( + context, + title: 'Sign Out', + message: 'Are you sure you want to sign out?', + confirmText: 'Sign Out', + ).then((confirmed) { + if (confirmed) { + // TODO: Implement proper logout functionality when auth service is available + // ref.read(authServiceProvider.notifier).logout(); + NavigationService.navigateTo('/login', clearStack: true); + } + }); + } + + void _showTextSizeDialog(BuildContext context) { + // TODO: Implement text size adjustment dialog + } + + void _showModelSelector(BuildContext context) { + // TODO: Implement model selection dialog + } + + void _showClearHistoryDialog(BuildContext context, WidgetRef ref) { + // TODO: Implement clear history dialog + } + + void _handleExportData(BuildContext context) { + // TODO: Implement data export + } + + void _showAboutDialog(BuildContext context) { + showAboutDialog( + context: context, + applicationName: 'Conduit', + applicationVersion: '1.0.0', + applicationLegalese: '© 2024 Conduit Team', + ); + } + + void _navigateToHelp(BuildContext context) { + // TODO: Navigate to help page + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..9a6b575 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/services/navigation_service.dart'; +import 'core/widgets/error_boundary.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'core/providers/app_providers.dart'; +import 'shared/theme/app_theme.dart'; +import 'shared/theme/theme_extensions.dart'; +import 'shared/widgets/offline_indicator.dart'; +import 'features/auth/views/connect_signin_page.dart'; +import 'features/auth/providers/unified_auth_providers.dart'; +import 'core/auth/auth_state_manager.dart'; +import 'package:flutter/cupertino.dart'; +import 'features/onboarding/views/onboarding_sheet.dart'; +import 'features/chat/views/chat_page.dart'; +import 'features/navigation/views/splash_launcher_page.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final sharedPrefs = await SharedPreferences.getInstance(); + const secureStorage = FlutterSecureStorage(); + + runApp( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(sharedPrefs), + secureStorageProvider.overrideWithValue(secureStorage), + ], + child: const ConduitApp(), + ), + ); +} + +class ConduitApp extends ConsumerStatefulWidget { + const ConduitApp({super.key}); + + @override + ConsumerState createState() => _ConduitAppState(); +} + +class _ConduitAppState extends ConsumerState { + bool _attemptedSilentAutoLogin = false; + @override + void initState() { + super.initState(); + _initializeAppState(); + } + + Widget _buildInitialLoadingSkeleton(BuildContext context) { + // Replace skeleton with branded splash during initialization + return const SplashLauncherPage(); + } + + void _initializeAppState() { + // Initialize unified auth state manager and API integration synchronously + // This ensures auth state is loaded before first widget build + debugPrint('DEBUG: Initializing unified auth system'); + + // Initialize auth state manager (will handle token validation automatically) + ref.read(authStateManagerProvider); + + // Ensure API service auth integration is active + ref.read(authApiIntegrationProvider); + } + + @override + Widget build(BuildContext context) { + // Use select to watch only the specific themeMode property to reduce rebuilds + final themeMode = ref.watch(themeModeProvider.select((mode) => mode)); + + // Reduced debug noise - only log when necessary + // debugPrint('DEBUG: Building app'); + + // Determine the current theme based on themeMode + // Default to Conduit brand theme globally + final currentTheme = themeMode == ThemeMode.dark + ? AppTheme.conduitDarkTheme + : themeMode == ThemeMode.light + ? AppTheme.conduitLightTheme + : MediaQuery.platformBrightnessOf(context) == Brightness.dark + ? AppTheme.conduitDarkTheme + : AppTheme.conduitLightTheme; + + return AnimatedThemeWrapper( + theme: currentTheme, + duration: AnimationDuration.medium, + child: ErrorBoundary( + child: MaterialApp( + title: 'Conduit', + theme: AppTheme.conduitLightTheme, + darkTheme: AppTheme.conduitDarkTheme, + themeMode: themeMode, + debugShowCheckedModeBanner: false, + navigatorKey: NavigationService.navigatorKey, + builder: (context, child) { + // Keep a subtle fade for navigation transitions only + final wrapped = OfflineIndicator( + child: child ?? const SizedBox.shrink(), + ); + return wrapped; + }, + home: _getInitialPageWithReactiveState(), + onGenerateRoute: NavigationService.generateRoute, + navigatorObservers: [_NavigationObserver()], + ), + ), + ); + } + + Widget _getInitialPageWithReactiveState() { + return Consumer( + builder: (context, ref, child) { + // Watch for server connection state changes + final activeServerAsync = ref.watch(activeServerProvider); + final reviewerMode = ref.watch(reviewerModeProvider); + + if (reviewerMode) { + // In reviewer mode, skip server/auth flows and go to chat + NavigationService.setCurrentRoute(Routes.chat); + return const ChatPage(); + } + + return activeServerAsync.when( + data: (activeServer) { + if (activeServer == null) { + return const ConnectAndSignInPage(); + } + + // Server is connected, now check authentication reactively + final authNavState = ref.watch(authNavigationStateProvider); + + if (authNavState == AuthNavigationState.needsLogin) { + // Try one-shot silent login if credentials are saved + if (!_attemptedSilentAutoLogin) { + _attemptedSilentAutoLogin = true; + Future.microtask(() async { + try { + final hasCreds = await ref.read( + hasSavedCredentialsProvider2.future, + ); + if (hasCreds) { + await ref.read(silentLoginActionProvider); + } + } catch (_) { + // Ignore errors, fallback to showing unified page + } + }); + } + return const ConnectAndSignInPage(); + } + + if (authNavState == AuthNavigationState.loading) { + return _buildInitialLoadingSkeleton(context); + } + + if (authNavState == AuthNavigationState.error) { + return _buildErrorState( + ref.watch(authErrorProvider3) ?? 'Authentication error', + ); + } + + // User is authenticated, navigate directly to chat page + _initializeBackgroundResources(ref); + + // Set the current route for navigation tracking + NavigationService.setCurrentRoute(Routes.chat); + + return const ChatPage(); + }, + loading: () => _buildInitialLoadingSkeleton(context), + error: (error, stackTrace) { + debugPrint('DEBUG: Server provider error: $error'); + return _buildErrorState('Server connection failed: $error'); + }, + ); + }, + ); + } + + void _initializeBackgroundResources(WidgetRef ref) { + // Initialize resources in the background without blocking UI + Future.microtask(() async { + try { + // Get the API service + final api = ref.read(apiServiceProvider); + if (api == null) { + debugPrint( + 'DEBUG: API service not available for background initialization', + ); + return; + } + + // Explicitly get the current auth token and set it on the API service + final authToken = ref.read(authTokenProvider3); + if (authToken != null && authToken.isNotEmpty) { + api.updateAuthToken(authToken); + debugPrint('DEBUG: Background - Set auth token on API service'); + } else { + debugPrint('DEBUG: Background - No auth token available yet'); + return; + } + + // Initialize the token updater for future updates + ref.read(apiTokenUpdaterProvider); + + // Load models and set default in background + await ref.read(defaultModelProvider.future); + debugPrint('DEBUG: Background initialization completed'); + + // Onboarding: show once if not seen + final storage = ref.read(optimizedStorageServiceProvider); + final seen = await storage.getOnboardingSeen(); + if (!seen && mounted) { + await Future.delayed(const Duration(milliseconds: 300)); + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) async { + final navContext = NavigationService.navigatorKey.currentContext; + if (!mounted || navContext == null) return; + _showOnboarding(navContext); + await storage.setOnboardingSeen(true); + }); + } + } catch (e) { + debugPrint('DEBUG: Background initialization failed: $e'); + // Don't throw - this is background initialization + } + }); + } + + void _showOnboarding(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.modal), + ), + boxShadow: ConduitShadows.modal, + ), + child: const OnboardingSheet(), + ), + ); + } + + Widget _buildErrorState(String error) { + return Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + body: Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: IconSize.xxl + Spacing.md, + color: context.conduitTheme.error, + ), + const SizedBox(height: Spacing.md), + Text( + 'Initialization Failed', + style: TextStyle( + fontSize: AppTypography.headlineLarge, + fontWeight: FontWeight.bold, + color: context.conduitTheme.textPrimary, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + error, + style: TextStyle(color: context.conduitTheme.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.lg), + ElevatedButton( + onPressed: () { + // Restart the app + WidgetsBinding.instance.reassembleApplication(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: context.conduitTheme.buttonPrimary, + foregroundColor: context.conduitTheme.buttonPrimaryText, + ), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } +} + +class _NavigationObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + // Log navigation for debugging and analytics + debugPrint('DEBUG: Navigation - Pushed: ${route.settings.name}'); + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + debugPrint('DEBUG: Navigation - Popped: ${route.settings.name}'); + } +} diff --git a/lib/shared/services/brand_service.dart b/lib/shared/services/brand_service.dart new file mode 100644 index 0000000..bde4236 --- /dev/null +++ b/lib/shared/services/brand_service.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import '../theme/theme_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'dart:io' show Platform; +import '../theme/app_theme.dart'; + +/// Centralized service for consistent brand identity throughout the app +/// Uses the hub icon as the primary brand element +class BrandService { + BrandService._(); + + /// Primary brand icon - the hub icon + static IconData get primaryIcon => + Platform.isIOS ? CupertinoIcons.link_circle_fill : Icons.hub; + + /// Alternative brand icons for different contexts + static IconData get primaryIconOutlined => + Platform.isIOS ? CupertinoIcons.link_circle : Icons.hub_outlined; + static IconData get connectivityIcon => + Platform.isIOS ? CupertinoIcons.wifi : Icons.hub; + static IconData get networkIcon => + Platform.isIOS ? CupertinoIcons.globe : Icons.hub; + + /// Brand colors - these should be accessed through context.conduitTheme in UI components + static Color get primaryBrandColor => AppTheme.brandPrimary; + static Color get secondaryBrandColor => AppTheme.brandPrimaryLight; + static Color get accentBrandColor => AppTheme.brandPrimaryDark; + + /// Creates a branded icon with consistent styling + static Widget createBrandIcon({ + double size = 24, + Color? color, + IconData? icon, + bool useGradient = false, + bool addShadow = false, + }) { + final iconData = icon ?? primaryIcon; + final iconColor = color ?? primaryBrandColor; + + Widget iconWidget = Icon( + iconData, + size: size, + color: useGradient ? null : iconColor, + ); + + if (useGradient) { + iconWidget = ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => LinearGradient( + colors: [primaryBrandColor, secondaryBrandColor], + ).createShader(bounds), + child: Icon(iconData, size: size), + ); + } + + if (addShadow) { + iconWidget = Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: primaryBrandColor.withValues(alpha: 0.3), + blurRadius: size * 0.3, + offset: Offset(0, size * 0.1), + ), + ], + ), + child: iconWidget, + ); + } + + return iconWidget; + } + + /// Creates a branded avatar with the hub icon + static Widget createBrandAvatar({ + double size = 40, + Color? backgroundColor, + Color? iconColor, + bool useGradient = true, + String? fallbackText, + BuildContext? context, + }) { + final bgColor = backgroundColor ?? primaryBrandColor; + final iColor = + iconColor ?? (context?.conduitTheme.textInverse ?? AppTheme.neutral50); + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + gradient: useGradient + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [primaryBrandColor, secondaryBrandColor], + ) + : null, + color: useGradient ? null : bgColor, + borderRadius: BorderRadius.circular(size / 2), + boxShadow: [ + BoxShadow( + color: primaryBrandColor.withValues(alpha: 0.3), + blurRadius: size * 0.2, + offset: Offset(0, size * 0.1), + ), + ], + ), + child: fallbackText != null && fallbackText.isNotEmpty + ? Center( + child: Text( + fallbackText.toUpperCase(), + style: TextStyle( + color: iColor, + fontSize: size * 0.4, + fontWeight: FontWeight.w600, + ), + ), + ) + : Icon(primaryIcon, size: size * 0.5, color: iColor), + ); + } + + /// Creates a branded loading indicator + static Widget createBrandLoadingIndicator({ + double size = 24, + double strokeWidth = 2, + Color? color, + }) { + return SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: strokeWidth, + valueColor: AlwaysStoppedAnimation(color ?? primaryBrandColor), + ), + ); + } + + /// Creates a branded empty state icon + static Widget createBrandEmptyStateIcon({ + double size = 80, + Color? color, + bool showBackground = true, + BuildContext? context, + }) { + final iconColor = + color ?? (context?.conduitTheme.iconSecondary ?? AppTheme.neutral400); + + if (!showBackground) { + return createBrandIcon( + size: size, + color: iconColor, + icon: primaryIconOutlined, + ); + } + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: context?.conduitTheme.surfaceBackground ?? AppTheme.neutral700, + borderRadius: BorderRadius.circular(size / 2), + border: Border.all( + color: context?.conduitTheme.dividerColor ?? AppTheme.neutral600, + width: 2, + ), + ), + child: createBrandIcon( + size: size * 0.5, + color: iconColor, + icon: primaryIconOutlined, + ), + ); + } + + /// Creates a branded button with hub icon + static Widget createBrandButton({ + required String text, + required VoidCallback? onPressed, + bool isLoading = false, + IconData? icon, + double? width, + bool isSecondary = false, + BuildContext? context, + }) { + return SizedBox( + width: width, + height: 48, + child: ElevatedButton.icon( + onPressed: isLoading ? null : onPressed, + icon: isLoading + ? createBrandLoadingIndicator(size: IconSize.sm) + : createBrandIcon( + size: IconSize.md, + icon: icon ?? primaryIcon, + color: context?.conduitTheme.textInverse ?? AppTheme.neutral50, + ), + label: Text(text), + style: ElevatedButton.styleFrom( + backgroundColor: isSecondary + ? (context?.conduitTheme.buttonSecondary ?? AppTheme.neutral700) + : (context?.conduitTheme.buttonPrimary ?? primaryBrandColor), + foregroundColor: + context?.conduitTheme.buttonPrimaryText ?? AppTheme.neutral50, + disabledBackgroundColor: + context?.conduitTheme.buttonDisabled ?? AppTheme.neutral500, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + elevation: Elevation.none, + ), + ), + ); + } + + /// Brand-specific semantic labels for accessibility + static String get brandName => 'Conduit'; + static String get brandDescription => 'Your AI Conversation Hub'; + static String get connectionLabel => 'Hub Connection'; + static String get networkLabel => 'Network Hub'; + + /// Creates branded AppBar with consistent styling + static PreferredSizeWidget createBrandAppBar({ + required String title, + List? actions, + Widget? leading, + bool centerTitle = true, + double elevation = 0, + BuildContext? context, + }) { + return AppBar( + title: Text( + title, + style: (context != null ? context.conduitTheme.headingSmall : null) + ?.copyWith( + color: (context != null + ? context.conduitTheme.textPrimary + : null), + fontWeight: FontWeight.w600, + ), + ), + centerTitle: centerTitle, + elevation: elevation, + backgroundColor: context?.conduitTheme.surfaceBackground, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + leading: leading, + actions: actions, + ); + } + + /// Creates a branded splash screen logo + static Widget createSplashLogo({ + double size = 140, + bool animate = true, + BuildContext? context, + }) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + context?.conduitTheme.buttonPrimary ?? primaryBrandColor, + context?.conduitTheme.buttonPrimary.withValues(alpha: 0.8) ?? + secondaryBrandColor, + ], + ), + borderRadius: BorderRadius.circular(size / 2), + boxShadow: ConduitShadows.glow, + ), + child: Icon( + primaryIcon, + size: size * 0.5, + color: context?.conduitTheme.textInverse ?? AppTheme.neutral50, + ), + ); + } +} diff --git a/lib/shared/theme/app_theme.dart b/lib/shared/theme/app_theme.dart new file mode 100644 index 0000000..1568d75 --- /dev/null +++ b/lib/shared/theme/app_theme.dart @@ -0,0 +1,414 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'theme_extensions.dart'; + +class AppTheme { + // Brand accents (ChatGPT aesthetic) + static const Color brandPrimary = Color(0xFF4F46E5); // Indigo + static const Color brandPrimaryLight = Color(0xFF818CF8); + static const Color brandPrimaryDark = Color(0xFF4338CA); + + // Enhanced neutral palette for better contrast (WCAG AA compliant) + static const Color neutral900 = Color(0xFF000000); // Pure black + static const Color neutral800 = Color( + 0xFF0D0D0D, + ); // Darker for better contrast + static const Color neutral700 = Color(0xFF1A1A1A); + static const Color neutral600 = Color(0xFF2D2D2D); // Improved contrast + static const Color neutral500 = Color(0xFF404040); // Better middle gray + static const Color neutral400 = Color(0xFF525252); + static const Color neutral300 = Color(0xFF6B6B6B); // Improved contrast ratio + static const Color neutral200 = Color(0xFF9E9E9E); // Better readability + static const Color neutral100 = Color(0xFFD1D1D1); // Enhanced contrast + static const Color neutral50 = Color( + 0xFFF8F8F8, + ); // Softer white for reduced eye strain + + // Enhanced semantic colors for WCAG AA compliance + static const Color error = Color(0xFFDC2626); // Improved red contrast + static const Color errorDark = Color(0xFFB91C1C); // Darker red for dark theme + static const Color success = Color(0xFF059669); // Better green contrast + static const Color successDark = Color(0xFF047857); // Dark theme green + static const Color warning = Color(0xFFD97706); // Improved orange contrast + static const Color warningDark = Color(0xFFB45309); // Dark theme orange + static const Color info = Color(0xFF0284C7); // Better blue contrast + static const Color infoDark = Color(0xFF0369A1); // Dark theme blue + + // Brand aliases + static const Color primaryColor = brandPrimary; + static const Color secondaryColor = brandPrimaryLight; + static const Color surfaceColor = neutral50; + static const Color errorColor = error; + static const Color successColor = success; + + // Base Light Theme + static ThemeData lightTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: const ColorScheme.light( + primary: brandPrimary, + secondary: brandPrimaryLight, + surface: surfaceColor, + error: errorColor, + ), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), + TargetPlatform.iOS: ZoomPageTransitionsBuilder(), + TargetPlatform.linux: ZoomPageTransitionsBuilder(), + TargetPlatform.macOS: ZoomPageTransitionsBuilder(), + TargetPlatform.windows: ZoomPageTransitionsBuilder(), + }, + ), + splashFactory: NoSplash.splashFactory, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: Elevation.none, + backgroundColor: Colors.transparent, + foregroundColor: neutral800, + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: neutral50, + modalBackgroundColor: neutral50, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.modal), + ), + showDragHandle: false, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + vertical: Spacing.xs, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + ), + ), + cardTheme: CardThemeData( + elevation: Elevation.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + side: BorderSide(color: neutral200), + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: neutral900.withValues(alpha: 0.92), + contentTextStyle: GoogleFonts.inter( + color: neutral50, + fontSize: AppTypography.bodyMedium, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.snackbar), + ), + elevation: Elevation.high, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: neutral50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: primaryColor, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: errorColor, width: 1), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + ), + textTheme: GoogleFonts.interTextTheme(), + extensions: const [ConduitThemeExtension.auroraLight], + ); + + // Base Dark Theme + static ThemeData darkTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: Color(0xFF0A0D0C), + colorScheme: const ColorScheme.dark( + primary: brandPrimary, + secondary: brandPrimaryDark, + surface: Color(0xFF0A0D0C), + surfaceContainerHighest: neutral700, + onSurface: neutral50, + onSurfaceVariant: neutral300, + outline: neutral600, + error: error, + ), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), + TargetPlatform.iOS: ZoomPageTransitionsBuilder(), + TargetPlatform.linux: ZoomPageTransitionsBuilder(), + TargetPlatform.macOS: ZoomPageTransitionsBuilder(), + TargetPlatform.windows: ZoomPageTransitionsBuilder(), + }, + ), + splashFactory: NoSplash.splashFactory, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: Elevation.none, + backgroundColor: Colors.transparent, + foregroundColor: neutral50, + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: neutral900, + modalBackgroundColor: neutral900, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.modal), + ), + showDragHandle: false, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + vertical: Spacing.xs, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + ), + ), + cardTheme: CardThemeData( + elevation: Elevation.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + side: BorderSide(color: neutral800), + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: neutral800.withValues(alpha: 0.92), + contentTextStyle: GoogleFonts.inter( + color: neutral50, + fontSize: AppTypography.bodyMedium, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.snackbar), + ), + elevation: Elevation.high, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: neutral700, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: neutral600, width: 1), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: neutral600, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: brandPrimary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: error, width: 1), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + ), + textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme), + extensions: const [ConduitThemeExtension.dark], + ); + + // Conduit variants using brand colors + static ThemeData conduitLightTheme = lightTheme.copyWith( + colorScheme: lightTheme.colorScheme.copyWith( + primary: brandPrimary, + secondary: brandPrimaryLight, + surface: neutral50, + ), + extensions: const [ConduitThemeExtension.light], + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: Elevation.none, + backgroundColor: Colors.transparent, + foregroundColor: neutral800, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: neutral50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: brandPrimary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: error, width: 1), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + ), + ); + + static ThemeData conduitDarkTheme = darkTheme.copyWith( + scaffoldBackgroundColor: const Color(0xFF0A0D0C), + colorScheme: darkTheme.colorScheme.copyWith( + primary: brandPrimary, + secondary: brandPrimaryDark, + surface: const Color(0xFF0A0D0C), + surfaceContainerHighest: neutral700, + ), + extensions: const [ConduitThemeExtension.dark], + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: Elevation.none, + backgroundColor: Colors.transparent, + foregroundColor: neutral50, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: neutral700, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: neutral600, width: 1), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: neutral600, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: brandPrimary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: const BorderSide(color: error, width: 1), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + ), + ); + + // Classic Conduit variants for runtime switching + // Removed classic Conduit variants from public API to keep Aurora only + + // Platform-specific theming helpers + static CupertinoThemeData cupertinoTheme(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return CupertinoThemeData( + brightness: isDark ? Brightness.dark : Brightness.light, + primaryColor: brandPrimary, + scaffoldBackgroundColor: isDark ? neutral900 : neutral50, + barBackgroundColor: isDark ? neutral900 : neutral50, + ); + } +} + +/// Animated theme wrapper for smooth theme transitions +class AnimatedThemeWrapper extends StatefulWidget { + final Widget child; + final ThemeData theme; + final Duration duration; + + const AnimatedThemeWrapper({ + super.key, + required this.child, + required this.theme, + this.duration = const Duration(milliseconds: 250), + }); + + @override + State createState() => _AnimatedThemeWrapperState(); +} + +class _AnimatedThemeWrapperState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + ThemeData? _previousTheme; + + @override + void initState() { + super.initState(); + _controller = AnimationController(duration: widget.duration, vsync: this); + _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); + _previousTheme = widget.theme; + } + + @override + void didUpdateWidget(AnimatedThemeWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.theme != widget.theme) { + _previousTheme = oldWidget.theme; + _controller.forward(from: 0); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Theme( + data: ThemeData.lerp( + _previousTheme ?? widget.theme, + widget.theme, + _animation.value, + ), + child: widget.child, + ); + }, + ); + } +} + +/// Theme transition widget for individual components +class ThemeTransition extends StatelessWidget { + final Widget child; + final Duration duration; + + const ThemeTransition({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 200), + }); + + @override + Widget build(BuildContext context) { + return child.animate().fadeIn(duration: duration); + } +} + +// Typography, spacing, and design token classes are now in theme_extensions.dart for consistency diff --git a/lib/shared/theme/theme_extensions.dart b/lib/shared/theme/theme_extensions.dart new file mode 100644 index 0000000..6425af6 --- /dev/null +++ b/lib/shared/theme/theme_extensions.dart @@ -0,0 +1,1808 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'app_theme.dart'; + +/// Extended theme data for consistent styling across the app +@immutable +class ConduitThemeExtension extends ThemeExtension { + // Chat-specific colors + final Color chatBubbleUser; + final Color chatBubbleAssistant; + final Color chatBubbleUserText; + final Color chatBubbleAssistantText; + final Color chatBubbleUserBorder; + final Color chatBubbleAssistantBorder; + + // Input and form colors + final Color inputBackground; + final Color inputBorder; + final Color inputBorderFocused; + final Color inputText; + final Color inputPlaceholder; + final Color inputError; + + // Card and surface colors + final Color cardBackground; + final Color cardBorder; + final Color cardShadow; + final Color surfaceBackground; + final Color surfaceContainer; + final Color surfaceContainerHighest; + + // Interactive element colors + final Color buttonPrimary; + final Color buttonPrimaryText; + final Color buttonSecondary; + final Color buttonSecondaryText; + final Color buttonDisabled; + final Color buttonDisabledText; + + // Status and feedback colors + final Color success; + final Color successBackground; + final Color error; + final Color errorBackground; + final Color warning; + final Color warningBackground; + final Color info; + final Color infoBackground; + + // Navigation and UI element colors + final Color dividerColor; + final Color navigationBackground; + final Color navigationSelected; + final Color navigationUnselected; + final Color navigationSelectedBackground; + + // Loading and animation colors + final Color shimmerBase; + final Color shimmerHighlight; + final Color loadingIndicator; + + // Text colors + final Color textPrimary; + final Color textSecondary; + final Color textTertiary; + final Color textInverse; + final Color textDisabled; + + // Icon colors + final Color iconPrimary; + final Color iconSecondary; + final Color iconDisabled; + final Color iconInverse; + + // Typography styles + final TextStyle? headingLarge; + final TextStyle? headingMedium; + final TextStyle? headingSmall; + final TextStyle? bodyLarge; + final TextStyle? bodyMedium; + final TextStyle? bodySmall; + final TextStyle? caption; + final TextStyle? label; + final TextStyle? code; + + const ConduitThemeExtension({ + // Chat-specific colors + required this.chatBubbleUser, + required this.chatBubbleAssistant, + required this.chatBubbleUserText, + required this.chatBubbleAssistantText, + required this.chatBubbleUserBorder, + required this.chatBubbleAssistantBorder, + + // Input and form colors + required this.inputBackground, + required this.inputBorder, + required this.inputBorderFocused, + required this.inputText, + required this.inputPlaceholder, + required this.inputError, + + // Card and surface colors + required this.cardBackground, + required this.cardBorder, + required this.cardShadow, + required this.surfaceBackground, + required this.surfaceContainer, + required this.surfaceContainerHighest, + + // Interactive element colors + required this.buttonPrimary, + required this.buttonPrimaryText, + required this.buttonSecondary, + required this.buttonSecondaryText, + required this.buttonDisabled, + required this.buttonDisabledText, + + // Status and feedback colors + required this.success, + required this.successBackground, + required this.error, + required this.errorBackground, + required this.warning, + required this.warningBackground, + required this.info, + required this.infoBackground, + + // Navigation and UI element colors + required this.dividerColor, + required this.navigationBackground, + required this.navigationSelected, + required this.navigationUnselected, + required this.navigationSelectedBackground, + + // Loading and animation colors + required this.shimmerBase, + required this.shimmerHighlight, + required this.loadingIndicator, + + // Text colors + required this.textPrimary, + required this.textSecondary, + required this.textTertiary, + required this.textInverse, + required this.textDisabled, + + // Icon colors + required this.iconPrimary, + required this.iconSecondary, + required this.iconDisabled, + required this.iconInverse, + + // Typography styles + this.headingLarge, + this.headingMedium, + this.headingSmall, + this.bodyLarge, + this.bodyMedium, + this.bodySmall, + this.caption, + this.label, + this.code, + }); + + @override + ConduitThemeExtension copyWith({ + // Chat-specific colors + Color? chatBubbleUser, + Color? chatBubbleAssistant, + Color? chatBubbleUserText, + Color? chatBubbleAssistantText, + Color? chatBubbleUserBorder, + Color? chatBubbleAssistantBorder, + + // Input and form colors + Color? inputBackground, + Color? inputBorder, + Color? inputBorderFocused, + Color? inputText, + Color? inputPlaceholder, + Color? inputError, + + // Card and surface colors + Color? cardBackground, + Color? cardBorder, + Color? cardShadow, + Color? surfaceBackground, + Color? surfaceContainer, + Color? surfaceContainerHighest, + + // Interactive element colors + Color? buttonPrimary, + Color? buttonPrimaryText, + Color? buttonSecondary, + Color? buttonSecondaryText, + Color? buttonDisabled, + Color? buttonDisabledText, + + // Status and feedback colors + Color? success, + Color? successBackground, + Color? error, + Color? errorBackground, + Color? warning, + Color? warningBackground, + Color? info, + Color? infoBackground, + + // Navigation and UI element colors + Color? dividerColor, + Color? navigationBackground, + Color? navigationSelected, + Color? navigationUnselected, + Color? navigationSelectedBackground, + + // Loading and animation colors + Color? shimmerBase, + Color? shimmerHighlight, + Color? loadingIndicator, + + // Text colors + Color? textPrimary, + Color? textSecondary, + Color? textTertiary, + Color? textInverse, + Color? textDisabled, + + // Icon colors + Color? iconPrimary, + Color? iconSecondary, + Color? iconDisabled, + Color? iconInverse, + + // Typography styles + TextStyle? headingLarge, + TextStyle? headingMedium, + TextStyle? headingSmall, + TextStyle? bodyLarge, + TextStyle? bodyMedium, + TextStyle? bodySmall, + TextStyle? caption, + TextStyle? label, + TextStyle? code, + }) { + return ConduitThemeExtension( + // Chat-specific colors + chatBubbleUser: chatBubbleUser ?? this.chatBubbleUser, + chatBubbleAssistant: chatBubbleAssistant ?? this.chatBubbleAssistant, + chatBubbleUserText: chatBubbleUserText ?? this.chatBubbleUserText, + chatBubbleAssistantText: + chatBubbleAssistantText ?? this.chatBubbleAssistantText, + chatBubbleUserBorder: chatBubbleUserBorder ?? this.chatBubbleUserBorder, + chatBubbleAssistantBorder: + chatBubbleAssistantBorder ?? this.chatBubbleAssistantBorder, + + // Input and form colors + inputBackground: inputBackground ?? this.inputBackground, + inputBorder: inputBorder ?? this.inputBorder, + inputBorderFocused: inputBorderFocused ?? this.inputBorderFocused, + inputText: inputText ?? this.inputText, + inputPlaceholder: inputPlaceholder ?? this.inputPlaceholder, + inputError: inputError ?? this.inputError, + + // Card and surface colors + cardBackground: cardBackground ?? this.cardBackground, + cardBorder: cardBorder ?? this.cardBorder, + cardShadow: cardShadow ?? this.cardShadow, + surfaceBackground: surfaceBackground ?? this.surfaceBackground, + surfaceContainer: surfaceContainer ?? this.surfaceContainer, + surfaceContainerHighest: + surfaceContainerHighest ?? this.surfaceContainerHighest, + + // Interactive element colors + buttonPrimary: buttonPrimary ?? this.buttonPrimary, + buttonPrimaryText: buttonPrimaryText ?? this.buttonPrimaryText, + buttonSecondary: buttonSecondary ?? this.buttonSecondary, + buttonSecondaryText: buttonSecondaryText ?? this.buttonSecondaryText, + buttonDisabled: buttonDisabled ?? this.buttonDisabled, + buttonDisabledText: buttonDisabledText ?? this.buttonDisabledText, + + // Status and feedback colors + success: success ?? this.success, + successBackground: successBackground ?? this.successBackground, + error: error ?? this.error, + errorBackground: errorBackground ?? this.errorBackground, + warning: warning ?? this.warning, + warningBackground: warningBackground ?? this.warningBackground, + info: info ?? this.info, + infoBackground: infoBackground ?? this.infoBackground, + + // Navigation and UI element colors + dividerColor: dividerColor ?? this.dividerColor, + navigationBackground: navigationBackground ?? this.navigationBackground, + navigationSelected: navigationSelected ?? this.navigationSelected, + navigationUnselected: navigationUnselected ?? this.navigationUnselected, + navigationSelectedBackground: + navigationSelectedBackground ?? this.navigationSelectedBackground, + + // Loading and animation colors + shimmerBase: shimmerBase ?? this.shimmerBase, + shimmerHighlight: shimmerHighlight ?? this.shimmerHighlight, + loadingIndicator: loadingIndicator ?? this.loadingIndicator, + + // Text colors + textPrimary: textPrimary ?? this.textPrimary, + textSecondary: textSecondary ?? this.textSecondary, + textTertiary: textTertiary ?? this.textTertiary, + textInverse: textInverse ?? this.textInverse, + textDisabled: textDisabled ?? this.textDisabled, + + // Icon colors + iconPrimary: iconPrimary ?? this.iconPrimary, + iconSecondary: iconSecondary ?? this.iconSecondary, + iconDisabled: iconDisabled ?? this.iconDisabled, + iconInverse: iconInverse ?? this.iconInverse, + + // Typography styles + headingLarge: headingLarge ?? this.headingLarge, + headingMedium: headingMedium ?? this.headingMedium, + headingSmall: headingSmall ?? this.headingSmall, + bodyLarge: bodyLarge ?? this.bodyLarge, + bodyMedium: bodyMedium ?? this.bodyMedium, + bodySmall: bodySmall ?? this.bodySmall, + caption: caption ?? this.caption, + label: label ?? this.label, + code: code ?? this.code, + ); + } + + @override + ConduitThemeExtension lerp( + ThemeExtension? other, + double t, + ) { + if (other is! ConduitThemeExtension) { + return this; + } + return ConduitThemeExtension( + // Chat-specific colors + chatBubbleUser: Color.lerp(chatBubbleUser, other.chatBubbleUser, t)!, + chatBubbleAssistant: Color.lerp( + chatBubbleAssistant, + other.chatBubbleAssistant, + t, + )!, + chatBubbleUserText: Color.lerp( + chatBubbleUserText, + other.chatBubbleUserText, + t, + )!, + chatBubbleAssistantText: Color.lerp( + chatBubbleAssistantText, + other.chatBubbleAssistantText, + t, + )!, + chatBubbleUserBorder: Color.lerp( + chatBubbleUserBorder, + other.chatBubbleUserBorder, + t, + )!, + chatBubbleAssistantBorder: Color.lerp( + chatBubbleAssistantBorder, + other.chatBubbleAssistantBorder, + t, + )!, + + // Input and form colors + inputBackground: Color.lerp(inputBackground, other.inputBackground, t)!, + inputBorder: Color.lerp(inputBorder, other.inputBorder, t)!, + inputBorderFocused: Color.lerp( + inputBorderFocused, + other.inputBorderFocused, + t, + )!, + inputText: Color.lerp(inputText, other.inputText, t)!, + inputPlaceholder: Color.lerp( + inputPlaceholder, + other.inputPlaceholder, + t, + )!, + inputError: Color.lerp(inputError, other.inputError, t)!, + + // Card and surface colors + cardBackground: Color.lerp(cardBackground, other.cardBackground, t)!, + cardBorder: Color.lerp(cardBorder, other.cardBorder, t)!, + cardShadow: Color.lerp(cardShadow, other.cardShadow, t)!, + surfaceBackground: Color.lerp( + surfaceBackground, + other.surfaceBackground, + t, + )!, + surfaceContainer: Color.lerp( + surfaceContainer, + other.surfaceContainer, + t, + )!, + surfaceContainerHighest: Color.lerp( + surfaceContainerHighest, + other.surfaceContainerHighest, + t, + )!, + + // Interactive element colors + buttonPrimary: Color.lerp(buttonPrimary, other.buttonPrimary, t)!, + buttonPrimaryText: Color.lerp( + buttonPrimaryText, + other.buttonPrimaryText, + t, + )!, + buttonSecondary: Color.lerp(buttonSecondary, other.buttonSecondary, t)!, + buttonSecondaryText: Color.lerp( + buttonSecondaryText, + other.buttonSecondaryText, + t, + )!, + buttonDisabled: Color.lerp(buttonDisabled, other.buttonDisabled, t)!, + buttonDisabledText: Color.lerp( + buttonDisabledText, + other.buttonDisabledText, + t, + )!, + + // Status and feedback colors + success: Color.lerp(success, other.success, t)!, + successBackground: Color.lerp( + successBackground, + other.successBackground, + t, + )!, + error: Color.lerp(error, other.error, t)!, + errorBackground: Color.lerp(errorBackground, other.errorBackground, t)!, + warning: Color.lerp(warning, other.warning, t)!, + warningBackground: Color.lerp( + warningBackground, + other.warningBackground, + t, + )!, + info: Color.lerp(info, other.info, t)!, + infoBackground: Color.lerp(infoBackground, other.infoBackground, t)!, + + // Navigation and UI element colors + dividerColor: Color.lerp(dividerColor, other.dividerColor, t)!, + navigationBackground: Color.lerp( + navigationBackground, + other.navigationBackground, + t, + )!, + navigationSelected: Color.lerp( + navigationSelected, + other.navigationSelected, + t, + )!, + navigationUnselected: Color.lerp( + navigationUnselected, + other.navigationUnselected, + t, + )!, + navigationSelectedBackground: Color.lerp( + navigationSelectedBackground, + other.navigationSelectedBackground, + t, + )!, + + // Loading and animation colors + shimmerBase: Color.lerp(shimmerBase, other.shimmerBase, t)!, + shimmerHighlight: Color.lerp( + shimmerHighlight, + other.shimmerHighlight, + t, + )!, + loadingIndicator: Color.lerp( + loadingIndicator, + other.loadingIndicator, + t, + )!, + + // Text colors + textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!, + textSecondary: Color.lerp(textSecondary, other.textSecondary, t)!, + textTertiary: Color.lerp(textTertiary, other.textTertiary, t)!, + textInverse: Color.lerp(textInverse, other.textInverse, t)!, + textDisabled: Color.lerp(textDisabled, other.textDisabled, t)!, + + // Icon colors + iconPrimary: Color.lerp(iconPrimary, other.iconPrimary, t)!, + iconSecondary: Color.lerp(iconSecondary, other.iconSecondary, t)!, + iconDisabled: Color.lerp(iconDisabled, other.iconDisabled, t)!, + iconInverse: Color.lerp(iconInverse, other.iconInverse, t)!, + + // Typography styles + headingLarge: TextStyle.lerp(headingLarge, other.headingLarge, t), + headingMedium: TextStyle.lerp(headingMedium, other.headingMedium, t), + headingSmall: TextStyle.lerp(headingSmall, other.headingSmall, t), + bodyLarge: TextStyle.lerp(bodyLarge, other.bodyLarge, t), + bodyMedium: TextStyle.lerp(bodyMedium, other.bodyMedium, t), + bodySmall: TextStyle.lerp(bodySmall, other.bodySmall, t), + caption: TextStyle.lerp(caption, other.caption, t), + label: TextStyle.lerp(label, other.label, t), + code: TextStyle.lerp(code, other.code, t), + ); + } + + /// Dark theme extension + static const ConduitThemeExtension dark = ConduitThemeExtension( + // Chat-specific colors - Enhanced for production-grade look + chatBubbleUser: AppTheme.brandPrimary, + chatBubbleAssistant: Color(0xFF0E1010), + chatBubbleUserText: AppTheme.neutral50, + chatBubbleAssistantText: AppTheme.neutral50, + chatBubbleUserBorder: AppTheme.brandPrimaryDark, + chatBubbleAssistantBorder: Color(0xFF1A1D1C), + // Input and form colors + inputBackground: Color(0xFF141615), + inputBorder: AppTheme.neutral600, + inputBorderFocused: AppTheme.brandPrimary, + inputText: AppTheme.neutral50, + inputPlaceholder: AppTheme.neutral400, + inputError: AppTheme.error, + + // Card and surface colors - Enhanced depth and hierarchy + cardBackground: Color(0xFF0C0F0E), + cardBorder: Color(0xFF151918), + cardShadow: AppTheme.neutral900, + surfaceBackground: Color(0xFF0A0D0C), + surfaceContainer: Color(0xFF0C0F0E), + surfaceContainerHighest: Color(0xFF121514), + + // Interactive element colors - More vibrant and accessible + buttonPrimary: AppTheme.brandPrimary, + buttonPrimaryText: AppTheme.neutral50, + buttonSecondary: Color(0xFF151918), + buttonSecondaryText: AppTheme.neutral50, + buttonDisabled: AppTheme.neutral600, + buttonDisabledText: AppTheme.neutral400, + + // Status and feedback colors - Enhanced visibility + success: Color(0xFF22C55E), + successBackground: Color(0xFF14532D), + error: Color(0xFFEF4444), + errorBackground: Color(0xFF7F1D1D), + warning: Color(0xFFF59E0B), + warningBackground: Color(0xFF7C2D12), + info: Color(0xFF38BDF8), + infoBackground: Color(0xFF0C4A6E), + + // Navigation and UI element colors - Enhanced contrast + dividerColor: Color(0xFF1A1D1C), + navigationBackground: Color(0xFF0A0D0C), + navigationSelected: AppTheme.brandPrimary, + navigationUnselected: AppTheme.neutral400, + navigationSelectedBackground: AppTheme.brandPrimary, + + // Loading and animation colors - Enhanced visibility + shimmerBase: Color(0xFF121514), + shimmerHighlight: Color(0xFF1A1D1C), + loadingIndicator: AppTheme.brandPrimary, + // Text colors - Enhanced hierarchy + textPrimary: AppTheme.neutral50, + textSecondary: Color(0xFFBAC2C0), + textTertiary: AppTheme.neutral400, + textInverse: AppTheme.neutral900, + textDisabled: AppTheme.neutral600, + + // Icon colors - Enhanced visibility + iconPrimary: AppTheme.neutral50, + iconSecondary: Color(0xFFA0A8A5), + iconDisabled: AppTheme.neutral600, + iconInverse: AppTheme.neutral900, + + // Typography styles + headingLarge: TextStyle( + fontSize: AppTypography.displaySmall, + fontWeight: FontWeight.w700, + color: AppTheme.neutral50, + height: 1.2, + ), + headingMedium: TextStyle( + fontSize: AppTypography.headlineLarge, + fontWeight: FontWeight.w600, + color: AppTheme.neutral50, + height: 1.3, + ), + headingSmall: TextStyle( + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + color: AppTheme.neutral50, + height: 1.4, + ), + bodyLarge: TextStyle( + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w400, + color: AppTheme.neutral50, + height: 1.5, + ), + bodyMedium: TextStyle( + fontSize: AppTypography.bodyMedium, + fontWeight: FontWeight.w400, + color: AppTheme.neutral50, + height: 1.5, + ), + bodySmall: TextStyle( + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w400, + color: Color(0xFFD1D5DB), // Enhanced contrast + height: 1.4, + ), + caption: TextStyle( + fontSize: AppTypography.labelMedium, + fontWeight: FontWeight.w500, + color: AppTheme.neutral300, + height: 1.3, + letterSpacing: 0.5, + ), + label: TextStyle( + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w500, + color: Color(0xFFD1D5DB), // Enhanced contrast + height: 1.3, + ), + code: TextStyle( + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w400, + color: Color(0xFFD1D5DB), // Enhanced contrast + height: 1.4, + fontFamily: AppTypography.monospaceFontFamily, + ), + ); + + /// Light theme extension + static const ConduitThemeExtension light = ConduitThemeExtension( + // Chat-specific colors - Enhanced for production-grade look + chatBubbleUser: AppTheme.brandPrimary, + chatBubbleAssistant: Color(0xFFF7F7F7), + chatBubbleUserText: AppTheme.neutral50, + chatBubbleAssistantText: Color(0xFF1C1C1C), + chatBubbleUserBorder: AppTheme.brandPrimaryDark, + chatBubbleAssistantBorder: Color(0xFFE7E7E7), + // Input and form colors + inputBackground: AppTheme.neutral50, + inputBorder: AppTheme.neutral200, + inputBorderFocused: AppTheme.brandPrimary, + inputText: AppTheme.neutral900, + inputPlaceholder: AppTheme.neutral500, + inputError: AppTheme.error, + + // Card and surface colors - Enhanced depth and hierarchy + cardBackground: AppTheme.neutral50, + cardBorder: Color(0xFFE7E7E7), + cardShadow: Color(0xFFF3F4F6), + surfaceBackground: AppTheme.neutral50, + surfaceContainer: Color(0xFFF7F7F7), + surfaceContainerHighest: Color(0xFFF0F1F1), + // Interactive element colors - More vibrant and accessible + buttonPrimary: AppTheme.brandPrimary, + buttonPrimaryText: AppTheme.neutral50, + buttonSecondary: Color(0xFFF0F1F1), + buttonSecondaryText: Color(0xFF1C1C1C), + buttonDisabled: AppTheme.neutral300, + buttonDisabledText: AppTheme.neutral500, + + // Status and feedback colors - Enhanced visibility + success: Color(0xFF16A34A), + successBackground: Color(0xFFEFFBF3), + error: Color(0xFFDC2626), + errorBackground: Color(0xFFFDECEC), + warning: Color(0xFFD97706), + warningBackground: Color(0xFFFEF6E7), + info: Color(0xFF0284C7), + infoBackground: Color(0xFFE8F4FD), + + // Navigation and UI element colors - Enhanced contrast + dividerColor: Color(0xFFE7E7E7), + navigationBackground: AppTheme.neutral50, + navigationSelected: AppTheme.brandPrimary, + navigationUnselected: AppTheme.neutral600, + navigationSelectedBackground: AppTheme.brandPrimary, + + // Loading and animation colors - Enhanced visibility + shimmerBase: Color(0xFFF3F4F6), + shimmerHighlight: AppTheme.neutral50, + loadingIndicator: AppTheme.brandPrimary, + // Text colors - Enhanced hierarchy + textPrimary: Color(0xFF1C1C1C), + textSecondary: Color(0xFF3A3F3E), + textTertiary: AppTheme.neutral500, + textInverse: AppTheme.neutral50, + textDisabled: AppTheme.neutral400, + + // Icon colors - Enhanced visibility + iconPrimary: Color(0xFF1C1C1C), + iconSecondary: Color(0xFF666C6A), + iconDisabled: AppTheme.neutral400, + iconInverse: AppTheme.neutral50, + + // Typography styles + headingLarge: TextStyle( + fontSize: AppTypography.displaySmall, + fontWeight: FontWeight.w700, + color: Color(0xFF111827), // Better contrast + height: 1.2, + ), + headingMedium: TextStyle( + fontSize: AppTypography.headlineLarge, + fontWeight: FontWeight.w600, + color: Color(0xFF111827), // Better contrast + height: 1.3, + ), + headingSmall: TextStyle( + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + color: Color(0xFF111827), // Better contrast + height: 1.4, + ), + bodyLarge: TextStyle( + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w400, + color: Color(0xFF111827), // Better contrast + height: 1.5, + ), + bodyMedium: TextStyle( + fontSize: AppTypography.bodyMedium, + fontWeight: FontWeight.w400, + color: Color(0xFF374151), // Better contrast + height: 1.5, + ), + bodySmall: TextStyle( + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w400, + color: Color(0xFF6B7280), // Better contrast + height: 1.4, + ), + caption: TextStyle( + fontSize: AppTypography.labelMedium, + fontWeight: FontWeight.w500, + color: AppTheme.neutral500, + height: 1.3, + letterSpacing: 0.5, + ), + label: TextStyle( + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w500, + color: Color(0xFF374151), // Better contrast + height: 1.3, + ), + code: TextStyle( + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w400, + color: Color(0xFF374151), // Better contrast + height: 1.4, + fontFamily: AppTypography.monospaceFontFamily, + ), + ); + + // Aurora palette: original, cool cyan-teal with midnight accents + static const ConduitThemeExtension auroraDark = ConduitThemeExtension( + // Chat-specific colors + chatBubbleUser: Color(0xFF0EA5A5), + chatBubbleAssistant: Color(0xFF111827), + chatBubbleUserText: AppTheme.neutral50, + chatBubbleAssistantText: AppTheme.neutral50, + chatBubbleUserBorder: Color(0xFF0EA5A5), + chatBubbleAssistantBorder: Color(0xFF1F2937), + // Input and form colors + inputBackground: AppTheme.neutral700, + inputBorder: AppTheme.neutral600, + inputBorderFocused: Color(0xFF06B6D4), + inputText: AppTheme.neutral50, + inputPlaceholder: AppTheme.neutral400, + inputError: AppTheme.error, + // Card and surface colors + cardBackground: Color(0xFF0B1220), + cardBorder: Color(0xFF1F2A37), + cardShadow: AppTheme.neutral900, + surfaceBackground: Color(0xFF0A0F1A), + surfaceContainer: Color(0xFF0F172A), + surfaceContainerHighest: Color(0xFF111827), + // Interactive element colors + buttonPrimary: Color(0xFF06B6D4), + buttonPrimaryText: AppTheme.neutral50, + buttonSecondary: Color(0xFF1E293B), + buttonSecondaryText: AppTheme.neutral50, + buttonDisabled: AppTheme.neutral600, + buttonDisabledText: AppTheme.neutral400, + // Status and feedback colors + success: Color(0xFF22C55E), + successBackground: Color(0xFF14532D), + error: Color(0xFFEF4444), + errorBackground: Color(0xFF7F1D1D), + warning: Color(0xFFF59E0B), + warningBackground: Color(0xFF7C2D12), + info: Color(0xFF38BDF8), + infoBackground: Color(0xFF0C4A6E), + // Navigation and UI element colors + dividerColor: Color(0xFF334155), + navigationBackground: Color(0xFF0B1220), + navigationSelected: Color(0xFF06B6D4), + navigationUnselected: AppTheme.neutral400, + navigationSelectedBackground: Color(0xFF06B6D4), + // Loading and animation colors + shimmerBase: Color(0xFF0F172A), + shimmerHighlight: Color(0xFF1F2937), + loadingIndicator: Color(0xFF06B6D4), + // Text colors + textPrimary: AppTheme.neutral50, + textSecondary: Color(0xFFE5E7EB), + textTertiary: AppTheme.neutral400, + textInverse: AppTheme.neutral900, + textDisabled: AppTheme.neutral600, + // Icon colors + iconPrimary: AppTheme.neutral50, + iconSecondary: Color(0xFF94A3B8), + iconDisabled: AppTheme.neutral600, + iconInverse: AppTheme.neutral900, + // Typography styles (reuse base sizes with aurora accent implied by colors) + headingLarge: null, + headingMedium: null, + headingSmall: null, + bodyLarge: null, + bodyMedium: null, + bodySmall: null, + caption: null, + label: null, + code: null, + ); + + static const ConduitThemeExtension auroraLight = ConduitThemeExtension( + // Chat-specific colors + chatBubbleUser: Color(0xFF0EA5A5), + chatBubbleAssistant: Color(0xFFF8FAFC), + chatBubbleUserText: AppTheme.neutral50, + chatBubbleAssistantText: Color(0xFF0F172A), + chatBubbleUserBorder: Color(0xFF0EA5A5), + chatBubbleAssistantBorder: Color(0xFFE2E8F0), + // Input and form colors + inputBackground: AppTheme.neutral50, + inputBorder: Color(0xFFE2E8F0), + inputBorderFocused: Color(0xFF06B6D4), + inputText: Color(0xFF0F172A), + inputPlaceholder: AppTheme.neutral500, + inputError: AppTheme.error, + // Card and surface colors + cardBackground: AppTheme.neutral50, + cardBorder: Color(0xFFE2E8F0), + cardShadow: Color(0xFFF1F5F9), + surfaceBackground: AppTheme.neutral50, + surfaceContainer: Color(0xFFF8FAFC), + surfaceContainerHighest: Color(0xFFF1F5F9), + // Interactive element colors + buttonPrimary: Color(0xFF06B6D4), + buttonPrimaryText: AppTheme.neutral50, + buttonSecondary: Color(0xFFE2E8F0), + buttonSecondaryText: Color(0xFF0F172A), + buttonDisabled: AppTheme.neutral300, + buttonDisabledText: AppTheme.neutral500, + // Status and feedback colors + success: Color(0xFF16A34A), + successBackground: Color(0xFFEFFBF3), + error: Color(0xFFDC2626), + errorBackground: Color(0xFFFDECEC), + warning: Color(0xFFD97706), + warningBackground: Color(0xFFFEF6E7), + info: Color(0xFF0284C7), + infoBackground: Color(0xFFE8F4FD), + // Navigation and UI element colors + dividerColor: Color(0xFFE2E8F0), + navigationBackground: AppTheme.neutral50, + navigationSelected: Color(0xFF06B6D4), + navigationUnselected: AppTheme.neutral600, + navigationSelectedBackground: Color(0xFF06B6D4), + // Loading and animation colors + shimmerBase: Color(0xFFF1F5F9), + shimmerHighlight: AppTheme.neutral50, + loadingIndicator: Color(0xFF06B6D4), + // Text colors + textPrimary: Color(0xFF0F172A), + textSecondary: Color(0xFF334155), + textTertiary: AppTheme.neutral500, + textInverse: AppTheme.neutral50, + textDisabled: AppTheme.neutral400, + // Icon colors + iconPrimary: Color(0xFF0F172A), + iconSecondary: Color(0xFF64748B), + iconDisabled: AppTheme.neutral400, + iconInverse: AppTheme.neutral50, + // Typography styles (inherit from base) + headingLarge: null, + headingMedium: null, + headingSmall: null, + bodyLarge: null, + bodyMedium: null, + bodySmall: null, + caption: null, + label: null, + code: null, + ); + + // Serenity palette: soft sage greens with gentle neutrals + static const ConduitThemeExtension serenityDark = ConduitThemeExtension( + // Chat-specific colors + chatBubbleUser: Color(0xFF4F9D88), + chatBubbleAssistant: Color(0xFF0A100E), + chatBubbleUserText: Color(0xFFD7E3DE), + chatBubbleAssistantText: Color(0xFFD7E3DE), + chatBubbleUserBorder: Color(0xFF15201B), + chatBubbleAssistantBorder: Color(0xFF121A16), + // Input and form colors + inputBackground: Color(0xFF0A100E), + inputBorder: Color(0xFF15201B), + inputBorderFocused: Color(0xFF4F9D88), + inputText: Color(0xFFD7E3DE), + inputPlaceholder: Color(0xFF7C8A84), + inputError: AppTheme.error, + // Card and surface colors + cardBackground: Color(0xFF0A100E), + cardBorder: Color(0xFF121A16), + cardShadow: AppTheme.neutral900, + surfaceBackground: Color(0xFF080D0B), + surfaceContainer: Color(0xFF0A100E), + surfaceContainerHighest: Color(0xFF0D1411), + // Interactive element colors + buttonPrimary: Color(0xFF4F9D88), + buttonPrimaryText: Color(0xFFD7E3DE), + buttonSecondary: Color(0xFF101613), + buttonSecondaryText: Color(0xFFD7E3DE), + buttonDisabled: AppTheme.neutral600, + buttonDisabledText: AppTheme.neutral400, + // Status and feedback colors + success: Color(0xFF22C55E), + successBackground: Color(0xFF14532D), + error: Color(0xFFEF4444), + errorBackground: Color(0xFF7F1D1D), + warning: Color(0xFFF59E0B), + warningBackground: Color(0xFF7C2D12), + info: Color(0xFF38BDF8), + infoBackground: Color(0xFF0C4A6E), + // Navigation and UI element colors + dividerColor: Color(0xFF15201B), + navigationBackground: Color(0xFF080D0B), + navigationSelected: Color(0xFF4F9D88), + navigationUnselected: AppTheme.neutral400, + navigationSelectedBackground: Color(0xFF4F9D88), + // Loading and animation colors + shimmerBase: Color(0xFF0A100E), + shimmerHighlight: Color(0xFF121A16), + loadingIndicator: Color(0xFF4F9D88), + // Text colors + textPrimary: Color(0xFFD7E3DE), + textSecondary: Color(0xFFA8B6AF), + textTertiary: AppTheme.neutral400, + textInverse: AppTheme.neutral900, + textDisabled: AppTheme.neutral600, + // Icon colors + iconPrimary: Color(0xFFD7E3DE), + iconSecondary: Color(0xFFA3B3AD), + iconDisabled: AppTheme.neutral600, + iconInverse: AppTheme.neutral900, + // Typography styles (inherit from base) + headingLarge: null, + headingMedium: null, + headingSmall: null, + bodyLarge: null, + bodyMedium: null, + bodySmall: null, + caption: null, + label: null, + code: null, + ); + + static const ConduitThemeExtension serenityLight = ConduitThemeExtension( + // Chat-specific colors + chatBubbleUser: Color(0xFF5FAE97), + chatBubbleAssistant: Color(0xFFF7FAF8), + chatBubbleUserText: AppTheme.neutral50, + chatBubbleAssistantText: Color(0xFF0F1A14), + chatBubbleUserBorder: Color(0xFF5FAE97), + chatBubbleAssistantBorder: Color(0xFFD5E3DB), + // Input and form colors + inputBackground: Color(0xFFF7FAF8), + inputBorder: Color(0xFFD5E3DB), + inputBorderFocused: Color(0xFF4F9D88), + inputText: Color(0xFF0F1A14), + inputPlaceholder: AppTheme.neutral500, + inputError: AppTheme.error, + // Card and surface colors + cardBackground: Color(0xFFFAFBF9), + cardBorder: Color(0xFFD9E2DB), + cardShadow: Color(0xFFEEF2EE), + surfaceBackground: Color(0xFFF7FAF8), + surfaceContainer: Color(0xFFEFF4F1), + surfaceContainerHighest: Color(0xFFE6EEEA), + // Interactive element colors + buttonPrimary: Color(0xFF4F9D88), + buttonPrimaryText: AppTheme.neutral50, + buttonSecondary: Color(0xFFEEF2EE), + buttonSecondaryText: Color(0xFF1A241E), + buttonDisabled: AppTheme.neutral300, + buttonDisabledText: AppTheme.neutral500, + // Status and feedback colors + success: Color(0xFF16A34A), + successBackground: Color(0xFFEFFBF3), + error: Color(0xFFDC2626), + errorBackground: Color(0xFFFDECEC), + warning: Color(0xFFD97706), + warningBackground: Color(0xFFFEF6E7), + info: Color(0xFF0284C7), + infoBackground: Color(0xFFE8F4FD), + // Navigation and UI element colors + dividerColor: Color(0xFFD9E2DB), + navigationBackground: Color(0xFFF7FAF8), + navigationSelected: Color(0xFF4F9D88), + navigationUnselected: AppTheme.neutral600, + navigationSelectedBackground: Color(0xFF4F9D88), + // Loading and animation colors + shimmerBase: Color(0xFFEFF4F1), + shimmerHighlight: AppTheme.neutral50, + loadingIndicator: Color(0xFF4F9D88), + // Text colors + textPrimary: Color(0xFF0F1A14), + textSecondary: Color(0xFF2D3A34), + textTertiary: AppTheme.neutral500, + textInverse: AppTheme.neutral50, + textDisabled: AppTheme.neutral400, + // Icon colors + iconPrimary: Color(0xFF0F1A14), + iconSecondary: Color(0xFF53665C), + iconDisabled: AppTheme.neutral400, + iconInverse: AppTheme.neutral50, + // Typography styles (inherit from base) + headingLarge: null, + headingMedium: null, + headingSmall: null, + bodyLarge: null, + bodyMedium: null, + bodySmall: null, + caption: null, + label: null, + code: null, + ); +} + +/// Extension method to easily access Conduit theme from BuildContext +extension ConduitThemeContext on BuildContext { + ConduitThemeExtension get conduitTheme { + return Theme.of(this).extension() ?? + ConduitThemeExtension.dark; + } +} + +/// Consistent spacing values - Enhanced for production with better hierarchy +class Spacing { + // Base spacing scale (8pt grid system) + static const double xxs = 2.0; + static const double xs = 4.0; + static const double sm = 8.0; + static const double md = 16.0; + static const double lg = 24.0; + static const double xl = 32.0; + static const double xxl = 48.0; + static const double xxxl = 64.0; + + // Enhanced spacing for specific components with better hierarchy + static const double buttonPadding = 16.0; + static const double cardPadding = 20.0; + static const double inputPadding = 16.0; + static const double modalPadding = 24.0; + static const double messagePadding = 16.0; + static const double navigationPadding = 12.0; + static const double listItemPadding = 16.0; + static const double sectionPadding = 24.0; + static const double pagePadding = 20.0; + static const double screenPadding = 16.0; + + // Spacing for different densities with improved hierarchy + static const double compact = 8.0; + static const double comfortable = 16.0; + static const double spacious = 24.0; + static const double extraSpacious = 32.0; + + // Specific component spacing with better consistency + static const double chatBubblePadding = 16.0; + static const double actionButtonPadding = 12.0; + static const double floatingButtonPadding = 16.0; + static const double bottomSheetPadding = 24.0; + static const double dialogPadding = 20.0; + static const double snackbarPadding = 16.0; + + // Layout spacing with improved hierarchy + static const double gridGap = 16.0; + static const double listGap = 12.0; + static const double sectionGap = 32.0; + static const double contentGap = 24.0; + + // Enhanced spacing for better visual hierarchy + static const double micro = 4.0; + static const double small = 8.0; + static const double medium = 16.0; + static const double large = 24.0; + static const double extraLarge = 32.0; + static const double huge = 48.0; + static const double massive = 64.0; + + // Component-specific spacing + static const double iconSpacing = 8.0; + static const double textSpacing = 4.0; + static const double borderSpacing = 1.0; + static const double shadowSpacing = 2.0; +} + +/// Consistent border radius values - Enhanced for production with better hierarchy +class AppBorderRadius { + // Base radius scale + static const double xs = 4.0; + static const double sm = 8.0; + static const double md = 12.0; + static const double lg = 16.0; + static const double xl = 24.0; + static const double round = 999.0; + + // Enhanced radius values for specific components with better hierarchy + static const double button = 12.0; + static const double card = 16.0; + static const double input = 12.0; + static const double modal = 20.0; + static const double messageBubble = 18.0; + static const double navigation = 12.0; + static const double avatar = 50.0; + static const double badge = 20.0; + static const double chip = 16.0; + static const double tooltip = 8.0; + + // Border radius for different sizes with improved hierarchy + static const double small = 6.0; + static const double medium = 12.0; + static const double large = 18.0; + static const double extraLarge = 24.0; + static const double pill = 999.0; + + // Specific component radius with better consistency + static const double chatBubble = 20.0; + static const double actionButton = 14.0; + static const double floatingButton = 28.0; + static const double bottomSheet = 24.0; + static const double dialog = 16.0; + static const double snackbar = 8.0; + + // Enhanced radius values for better visual hierarchy + static const double micro = 2.0; + static const double tiny = 4.0; + static const double standard = 8.0; + static const double comfortable = 12.0; + static const double spacious = 16.0; + static const double extraSpacious = 24.0; + static const double circular = 999.0; +} + +/// Consistent border width values - Enhanced for production +class BorderWidth { + static const double thin = 0.5; + static const double regular = 1.0; + static const double medium = 1.5; + static const double thick = 2.0; + + // Enhanced border widths for better visual hierarchy + static const double micro = 0.5; + static const double small = 1.0; + static const double standard = 1.5; + static const double large = 2.0; + static const double extraLarge = 3.0; +} + +/// Consistent elevation values - Enhanced for production with better hierarchy +class Elevation { + static const double none = 0.0; + static const double low = 2.0; + static const double medium = 4.0; + static const double high = 8.0; + static const double highest = 16.0; + + // Enhanced elevation values for better visual hierarchy + static const double micro = 1.0; + static const double small = 2.0; + static const double standard = 4.0; + static const double large = 8.0; + static const double extraLarge = 16.0; + static const double massive = 24.0; +} + +/// Helper class for consistent shadows - Enhanced for production with better hierarchy +class ConduitShadows { + static List get low => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + spreadRadius: 0, + ), + ]; + + static List get medium => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.12), + blurRadius: 16, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ]; + + static List get high => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.16), + blurRadius: 24, + offset: const Offset(0, 8), + spreadRadius: 0, + ), + ]; + + static List get glow => [ + BoxShadow( + color: AppTheme.brandPrimary.withValues(alpha: 0.25), + blurRadius: 20, + offset: const Offset(0, 0), + spreadRadius: 0, + ), + ]; + + // Enhanced shadows for specific components with better hierarchy + static List get card => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 3), + spreadRadius: 0, + ), + ]; + + static List get button => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.1), + blurRadius: 6, + offset: const Offset(0, 2), + spreadRadius: 0, + ), + ]; + + static List get modal => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.2), + blurRadius: 32, + offset: const Offset(0, 12), + spreadRadius: 0, + ), + ]; + + static List get navigation => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.08), + blurRadius: 16, + offset: const Offset(0, -2), + spreadRadius: 0, + ), + ]; + + static List get messageBubble => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 1), + spreadRadius: 0, + ), + ]; + + static List get input => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 1), + spreadRadius: 0, + ), + ]; + + // Dark theme specific shadows with better contrast + static List get darkCard => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.3), + blurRadius: 16, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ]; + + static List get darkModal => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.4), + blurRadius: 40, + offset: const Offset(0, 16), + spreadRadius: 0, + ), + ]; + + // Interactive shadows with better feedback + static List get pressed => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.15), + blurRadius: 4, + offset: const Offset(0, 1), + spreadRadius: 0, + ), + ]; + + static List get hover => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.12), + blurRadius: 12, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ]; + + // Enhanced shadows for better visual hierarchy + static List get micro => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.04), + blurRadius: 4, + offset: const Offset(0, 1), + spreadRadius: 0, + ), + ]; + + static List get small => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + spreadRadius: 0, + ), + ]; + + static List get standard => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 3), + spreadRadius: 0, + ), + ]; + + static List get large => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.12), + blurRadius: 16, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ]; + + static List get extraLarge => [ + BoxShadow( + color: AppTheme.neutral900.withValues(alpha: 0.16), + blurRadius: 24, + offset: const Offset(0, 8), + spreadRadius: 0, + ), + ]; +} + +/// Typography scale following Conduit design tokens - Enhanced for production +class AppTypography { + static const String fontFamily = 'Inter'; + static const String monospaceFontFamily = 'SF Mono'; + + // Letter spacing values - Enhanced for better readability + static const double letterSpacingTight = -0.5; + static const double letterSpacingNormal = 0.0; + static const double letterSpacingWide = 0.5; + static const double letterSpacingExtraWide = 1.0; + + // Font sizes - Enhanced scale for better hierarchy + static const double displayLarge = 48; + static const double displayMedium = 36; + static const double displaySmall = 32; + static const double headlineLarge = 28; + static const double headlineMedium = 24; + static const double headlineSmall = 20; + static const double bodyLarge = 18; + static const double bodyMedium = 16; + static const double bodySmall = 14; + static const double labelLarge = 16; + static const double labelMedium = 14; + static const double labelSmall = 12; + + // Text styles following Conduit design - Enhanced for production + static final TextStyle displayLargeStyle = GoogleFonts.inter( + fontSize: displayLarge, + fontWeight: FontWeight.w700, + letterSpacing: -0.8, + height: 1.1, + ); + + static final TextStyle displayMediumStyle = GoogleFonts.inter( + fontSize: displayMedium, + fontWeight: FontWeight.w700, + letterSpacing: -0.6, + height: 1.2, + ); + + static final TextStyle bodyLargeStyle = GoogleFonts.inter( + fontSize: bodyLarge, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.6, + ); + + static final TextStyle bodyMediumStyle = GoogleFonts.inter( + fontSize: bodyMedium, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.6, + ); + + static final TextStyle codeStyle = GoogleFonts.sourceCodePro( + fontSize: bodySmall, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.5, + ); + + // Additional styled text getters for convenience - Enhanced + static TextStyle get headlineLargeStyle => GoogleFonts.inter( + fontSize: headlineLarge, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + height: 1.3, + ); + + static TextStyle get headlineMediumStyle => GoogleFonts.inter( + fontSize: headlineMedium, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + height: 1.3, + ); + + static TextStyle get headlineSmallStyle => GoogleFonts.inter( + fontSize: headlineSmall, + fontWeight: FontWeight.w600, + letterSpacing: 0, + height: 1.4, + ); + + static TextStyle get bodySmallStyle => GoogleFonts.inter( + fontSize: bodySmall, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.5, + ); + + // Enhanced text styles for chat messages + static TextStyle get chatMessageStyle => GoogleFonts.inter( + fontSize: bodyMedium, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + height: 1.6, + ); + + static TextStyle get chatCodeStyle => GoogleFonts.sourceCodePro( + fontSize: bodySmall, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.5, + ); + + // Enhanced label styles + static TextStyle get labelStyle => GoogleFonts.inter( + fontSize: labelMedium, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + height: 1.4, + ); + + // Enhanced caption styles + static TextStyle get captionStyle => GoogleFonts.inter( + fontSize: labelSmall, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + height: 1.3, + ); + + // Enhanced typography for better hierarchy + static TextStyle get micro => GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + height: 1.4, + ); + + static TextStyle get tiny => GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + height: 1.4, + ); + + static TextStyle get small => GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.5, + ); + + static TextStyle get standard => GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.6, + ); + + static TextStyle get large => GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.6, + ); + + static TextStyle get extraLarge => GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.5, + ); + + static TextStyle get huge => GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + height: 1.3, + ); + + static TextStyle get massive => GoogleFonts.inter( + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + height: 1.2, + ); +} + +/// Consistent icon sizes - Enhanced for production with better hierarchy +class IconSize { + static const double xs = 12.0; + static const double sm = 16.0; + static const double md = 20.0; + static const double lg = 24.0; + static const double xl = 32.0; + static const double xxl = 48.0; + + // Enhanced icon sizes for specific components with better hierarchy + static const double button = 20.0; + static const double card = 24.0; + static const double input = 20.0; + static const double modal = 24.0; + static const double message = 18.0; + static const double navigation = 24.0; + static const double avatar = 40.0; + static const double badge = 16.0; + static const double chip = 18.0; + static const double tooltip = 16.0; + + // Icon sizes for different contexts with improved hierarchy + static const double micro = 12.0; + static const double small = 16.0; + static const double medium = 20.0; + static const double large = 24.0; + static const double extraLarge = 32.0; + static const double huge = 48.0; + + // Specific component icon sizes with better consistency + static const double chatBubble = 18.0; + static const double actionButton = 20.0; + static const double floatingButton = 24.0; + static const double bottomSheet = 24.0; + static const double dialog = 24.0; + static const double snackbar = 20.0; + static const double tabBar = 24.0; + static const double appBar = 24.0; + static const double listItem = 20.0; + static const double formField = 20.0; +} + +/// Alpha values for opacity/transparency - Enhanced for production with better hierarchy +class Alpha { + static const double subtle = 0.1; + static const double light = 0.3; + static const double medium = 0.5; + static const double strong = 0.7; + static const double intense = 0.9; + + // Enhanced alpha values for specific use cases with better hierarchy + static const double disabled = 0.38; + static const double overlay = 0.5; + static const double backdrop = 0.6; + static const double highlight = 0.12; + static const double pressed = 0.2; + static const double hover = 0.08; + static const double focus = 0.12; + static const double selected = 0.16; + static const double active = 0.24; + static const double inactive = 0.6; + + // Alpha values for different states with improved hierarchy + static const double primary = 1.0; + static const double secondary = 0.7; + static const double tertiary = 0.5; + static const double quaternary = 0.3; + static const double disabledText = 0.38; + static const double disabledIcon = 0.38; + static const double disabledBackground = 0.12; + + // Specific component alpha values with better consistency + static const double buttonPressed = 0.2; + static const double buttonHover = 0.08; + static const double cardHover = 0.04; + static const double inputFocus = 0.12; + static const double modalBackdrop = 0.6; + static const double snackbarBackground = 0.95; + static const double tooltipBackground = 0.9; + static const double badgeBackground = 0.1; + static const double chipBackground = 0.08; + static const double avatarBorder = 0.2; + + // Enhanced alpha values for better visual hierarchy + static const double micro = 0.05; + static const double tiny = 0.1; + static const double small = 0.2; + static const double standard = 0.3; + static const double large = 0.5; + static const double extraLarge = 0.7; + static const double huge = 0.9; +} + +/// Touch target sizes for accessibility compliance - Enhanced for production with better hierarchy +class TouchTarget { + static const double minimum = 44.0; + static const double comfortable = 48.0; + static const double large = 56.0; + + // Enhanced touch targets for specific components with better hierarchy + static const double button = 48.0; + static const double card = 48.0; + static const double input = 48.0; + static const double modal = 48.0; + static const double message = 44.0; + static const double navigation = 48.0; + static const double avatar = 48.0; + static const double badge = 32.0; + static const double chip = 32.0; + static const double tooltip = 32.0; + + // Touch targets for different contexts with improved hierarchy + static const double micro = 32.0; + static const double small = 40.0; + static const double medium = 48.0; + static const double standard = 56.0; + static const double extraLarge = 64.0; + static const double huge = 80.0; + + // Specific component touch targets with better consistency + static const double chatBubble = 44.0; + static const double actionButton = 48.0; + static const double floatingButton = 56.0; + static const double bottomSheet = 48.0; + static const double dialog = 48.0; + static const double snackbar = 48.0; + static const double tabBar = 48.0; + static const double appBar = 48.0; + static const double listItem = 48.0; + static const double formField = 48.0; + static const double iconButton = 48.0; + static const double textButton = 44.0; + static const double toggle = 48.0; + static const double slider = 48.0; + static const double checkbox = 48.0; + static const double radio = 48.0; +} + +/// Animation durations for consistent motion design - Enhanced for production with better hierarchy +class AnimationDuration { + static const Duration instant = Duration(milliseconds: 100); + static const Duration fast = Duration(milliseconds: 200); + static const Duration medium = Duration(milliseconds: 300); + static const Duration slow = Duration(milliseconds: 500); + static const Duration slower = Duration(milliseconds: 800); + static const Duration slowest = Duration(milliseconds: 1000); + static const Duration extraSlow = Duration(milliseconds: 1200); + static const Duration ultra = Duration(milliseconds: 1500); + static const Duration extended = Duration(seconds: 2); + static const Duration long = Duration(seconds: 4); + + // Enhanced durations for specific interactions with better hierarchy + static const Duration microInteraction = Duration(milliseconds: 150); + static const Duration buttonPress = Duration(milliseconds: 100); + static const Duration cardHover = Duration(milliseconds: 200); + static const Duration pageTransition = Duration(milliseconds: 400); + static const Duration modalPresentation = Duration(milliseconds: 500); + static const Duration typingIndicator = Duration(milliseconds: 800); + static const Duration messageAppear = Duration(milliseconds: 350); + static const Duration messageSlide = Duration(milliseconds: 400); + + // Enhanced durations for better visual hierarchy + static const Duration micro = Duration(milliseconds: 50); + static const Duration tiny = Duration(milliseconds: 100); + static const Duration small = Duration(milliseconds: 200); + static const Duration standard = Duration(milliseconds: 300); + static const Duration large = Duration(milliseconds: 500); + static const Duration extraLarge = Duration(milliseconds: 800); + static const Duration huge = Duration(milliseconds: 1200); +} + +/// Animation curves for consistent motion design - Enhanced for production with better hierarchy +class AnimationCurves { + static const Curve easeIn = Curves.easeIn; + static const Curve easeOut = Curves.easeOut; + static const Curve easeInOut = Curves.easeInOut; + static const Curve bounce = Curves.bounceOut; + static const Curve elastic = Curves.elasticOut; + static const Curve fastOutSlowIn = Curves.fastOutSlowIn; + static const Curve linear = Curves.linear; + + // Enhanced curves for specific interactions with better hierarchy + static const Curve buttonPress = Curves.easeOutCubic; + static const Curve cardHover = Curves.easeInOutCubic; + static const Curve messageSlide = Curves.easeOutCubic; + static const Curve typingIndicator = Curves.easeInOut; + static const Curve modalPresentation = Curves.easeOutBack; + static const Curve pageTransition = Curves.easeInOutCubic; + static const Curve microInteraction = Curves.easeOutQuart; + static const Curve spring = Curves.elasticOut; + + // Enhanced curves for better visual hierarchy + static const Curve micro = Curves.easeOutQuart; + static const Curve tiny = Curves.easeOutCubic; + static const Curve small = Curves.easeInOutCubic; + static const Curve standard = Curves.easeInOut; + static const Curve large = Curves.easeOutBack; + static const Curve extraLarge = Curves.elasticOut; + static const Curve huge = Curves.bounceOut; +} + +/// Common animation values - Enhanced for production with better hierarchy +class AnimationValues { + static const double fadeInOpacity = 0.0; + static const double fadeOutOpacity = 1.0; + static const Offset slideInFromTop = Offset(0, -0.05); + static const Offset slideInFromBottom = Offset(0, 0.05); + static const Offset slideInFromLeft = Offset(-0.05, 0); + static const Offset slideInFromRight = Offset(0.05, 0); + static const Offset slideCenter = Offset.zero; + static const double scaleMin = 0.0; + static const double scaleMax = 1.0; + static const double shimmerBegin = -1.0; + static const double shimmerEnd = 2.0; + + // Enhanced values for specific interactions with better hierarchy + static const double buttonScalePressed = 0.95; + static const double buttonScaleHover = 1.02; + static const double cardScaleHover = 1.01; + static const double messageSlideDistance = 0.1; + static const double typingIndicatorScale = 0.8; + static const double modalScale = 0.9; + static const double pageSlideDistance = 0.15; + static const double microInteractionScale = 0.98; + + // Enhanced values for better visual hierarchy + static const double micro = 0.95; + static const double tiny = 0.98; + static const double small = 1.01; + static const double standard = 1.02; + static const double large = 1.05; + static const double extraLarge = 1.1; + static const double huge = 1.2; +} + +/// Delay values for staggered animations - Enhanced for production with better hierarchy +class AnimationDelay { + static const Duration none = Duration.zero; + static const Duration short = Duration(milliseconds: 100); + static const Duration medium = Duration(milliseconds: 200); + static const Duration long = Duration(milliseconds: 400); + static const Duration extraLong = Duration(milliseconds: 600); + static const Duration ultra = Duration(milliseconds: 800); + + // Enhanced delays for specific interactions with better hierarchy + static const Duration microDelay = Duration(milliseconds: 50); + static const Duration buttonDelay = Duration(milliseconds: 75); + static const Duration cardDelay = Duration(milliseconds: 150); + static const Duration messageDelay = Duration(milliseconds: 100); + static const Duration typingDelay = Duration(milliseconds: 200); + static const Duration modalDelay = Duration(milliseconds: 300); + static const Duration pageDelay = Duration(milliseconds: 250); + static const Duration staggeredDelay = Duration(milliseconds: 50); + + // Enhanced delays for better visual hierarchy + static const Duration micro = Duration(milliseconds: 25); + static const Duration tiny = Duration(milliseconds: 50); + static const Duration small = Duration(milliseconds: 100); + static const Duration standard = Duration(milliseconds: 200); + static const Duration large = Duration(milliseconds: 400); + static const Duration extraLarge = Duration(milliseconds: 600); + static const Duration huge = Duration(milliseconds: 800); +} diff --git a/lib/shared/utils/keyboard_utils.dart b/lib/shared/utils/keyboard_utils.dart new file mode 100644 index 0000000..15c623d --- /dev/null +++ b/lib/shared/utils/keyboard_utils.dart @@ -0,0 +1,435 @@ +import 'package:flutter/material.dart'; +import '../theme/theme_extensions.dart'; +import 'package:flutter/services.dart'; +import 'dart:io' show Platform; + +/// Enhanced keyboard handling utilities for better UX +class KeyboardUtils { + KeyboardUtils._(); + + /// Dismiss keyboard with haptic feedback + static void dismissKeyboard(BuildContext context) { + final currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { + FocusManager.instance.primaryFocus?.unfocus(); + + // Add haptic feedback on iOS + if (Platform.isIOS) { + HapticFeedback.lightImpact(); + } + } + } + + /// Force dismiss keyboard immediately + static void forceDismissKeyboard() { + FocusManager.instance.primaryFocus?.unfocus(); + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + + /// Check if keyboard is currently visible + static bool isKeyboardVisible(BuildContext context) { + return MediaQuery.of(context).viewInsets.bottom > 0; + } + + /// Get keyboard height + static double getKeyboardHeight(BuildContext context) { + return MediaQuery.of(context).viewInsets.bottom; + } + + /// Move focus to next field + static void nextFocus(BuildContext context) { + FocusScope.of(context).nextFocus(); + } + + /// Move focus to previous field + static void previousFocus(BuildContext context) { + FocusScope.of(context).previousFocus(); + } + + /// Request focus for a specific node + static void requestFocus(BuildContext context, FocusNode focusNode) { + FocusScope.of(context).requestFocus(focusNode); + } + + /// Create a tap detector that dismisses keyboard when tapping outside text fields + static Widget dismissKeyboardOnTap({ + required BuildContext context, + required Widget child, + }) { + return GestureDetector( + onTap: () => dismissKeyboard(context), + // Let children handle taps first (e.g., TextField gains focus) + behavior: HitTestBehavior.deferToChild, + child: child, + ); + } +} + +/// Widget that automatically adjusts for keyboard visibility +class KeyboardAware extends StatefulWidget { + final Widget child; + final EdgeInsets? padding; + final bool maintainBottomViewPadding; + final Duration animationDuration; + final Curve animationCurve; + + const KeyboardAware({ + super.key, + required this.child, + this.padding, + this.maintainBottomViewPadding = true, + this.animationDuration = const Duration(milliseconds: 250), + this.animationCurve = Curves.easeInOut, + }); + + @override + State createState() => _KeyboardAwareState(); +} + +class _KeyboardAwareState extends State + with WidgetsBindingObserver { + double _keyboardHeight = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeMetrics() { + super.didChangeMetrics(); + final newKeyboardHeight = MediaQuery.of(context).viewInsets.bottom; + if (newKeyboardHeight != _keyboardHeight) { + setState(() { + _keyboardHeight = newKeyboardHeight; + }); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedPadding( + duration: widget.animationDuration, + curve: widget.animationCurve, + padding: EdgeInsets.only( + bottom: widget.maintainBottomViewPadding ? _keyboardHeight : 0, + ).add(widget.padding ?? EdgeInsets.zero), + child: widget.child, + ); + } +} + +/// Enhanced text field with better keyboard handling +class EnhancedTextField extends StatefulWidget { + final TextEditingController? controller; + final FocusNode? focusNode; + final String? hintText; + final String? labelText; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onTap; + final bool obscureText; + final bool enabled; + final int? maxLines; + final int? minLines; + final EdgeInsets? contentPadding; + final Widget? prefixIcon; + final Widget? suffixIcon; + final bool autofocus; + final bool dismissKeyboardOnSubmit; + + const EnhancedTextField({ + super.key, + this.controller, + this.focusNode, + this.hintText, + this.labelText, + this.keyboardType, + this.textInputAction, + this.onChanged, + this.onSubmitted, + this.onTap, + this.obscureText = false, + this.enabled = true, + this.maxLines = 1, + this.minLines, + this.contentPadding, + this.prefixIcon, + this.suffixIcon, + this.autofocus = false, + this.dismissKeyboardOnSubmit = true, + }); + + @override + State createState() => _EnhancedTextFieldState(); +} + +class _EnhancedTextFieldState extends State { + late FocusNode _focusNode; + bool _hasFocus = false; + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.addListener(_onFocusChanged); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChanged); + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + void _onFocusChanged() { + setState(() { + _hasFocus = _focusNode.hasFocus; + }); + } + + void _handleSubmitted(String value) { + widget.onSubmitted?.call(value); + + if (widget.dismissKeyboardOnSubmit) { + KeyboardUtils.dismissKeyboard(context); + } + + // Add haptic feedback + if (Platform.isIOS) { + HapticFeedback.lightImpact(); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + border: Border.all( + color: _hasFocus + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.inputBorder, + width: _hasFocus ? 2 : 1, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: TextField( + controller: widget.controller, + focusNode: _focusNode, + obscureText: widget.obscureText, + enabled: widget.enabled, + autofocus: widget.autofocus, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + maxLines: widget.maxLines, + minLines: widget.minLines, + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.bodyLarge, + ), + decoration: InputDecoration( + hintText: widget.hintText, + labelText: widget.labelText, + hintStyle: TextStyle(color: context.conduitTheme.inputPlaceholder), + labelStyle: TextStyle( + color: _hasFocus + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.textSecondary, + ), + prefixIcon: widget.prefixIcon, + suffixIcon: widget.suffixIcon, + contentPadding: + widget.contentPadding ?? + const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + ), + onChanged: widget.onChanged, + onSubmitted: _handleSubmitted, + onTap: widget.onTap, + ), + ); + } +} + +/// Smart keyboard handler that manages multiple text fields +class SmartKeyboardHandler extends StatefulWidget { + final List focusNodes; + final Widget child; + final VoidCallback? onDone; + + const SmartKeyboardHandler({ + super.key, + required this.focusNodes, + required this.child, + this.onDone, + }); + + @override + State createState() => _SmartKeyboardHandlerState(); +} + +class _SmartKeyboardHandlerState extends State { + int _currentIndex = -1; + + @override + void initState() { + super.initState(); + _setupFocusListeners(); + } + + void _setupFocusListeners() { + for (int i = 0; i < widget.focusNodes.length; i++) { + widget.focusNodes[i].addListener(() => _onFocusChanged(i)); + } + } + + void _onFocusChanged(int index) { + if (widget.focusNodes[index].hasFocus) { + setState(() { + _currentIndex = index; + }); + } + } + + void _moveToNext() { + if (_currentIndex < widget.focusNodes.length - 1) { + KeyboardUtils.requestFocus(context, widget.focusNodes[_currentIndex + 1]); + } else { + KeyboardUtils.dismissKeyboard(context); + widget.onDone?.call(); + } + } + + void _moveToPrevious() { + if (_currentIndex > 0) { + KeyboardUtils.requestFocus(context, widget.focusNodes[_currentIndex - 1]); + } + } + + @override + Widget build(BuildContext context) { + return Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.tab) { + if (HardwareKeyboard.instance.isShiftPressed) { + _moveToPrevious(); + } else { + _moveToNext(); + } + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: widget.child, + ); + } + + @override + void dispose() { + for (final focusNode in widget.focusNodes) { + focusNode.removeListener(() {}); + } + super.dispose(); + } +} + +/// Keyboard-aware scroll view that adjusts scroll position +class KeyboardAwareScrollView extends StatefulWidget { + final ScrollController? controller; + final Widget child; + final EdgeInsets? padding; + final bool reverse; + final Duration animationDuration; + + const KeyboardAwareScrollView({ + super.key, + this.controller, + required this.child, + this.padding, + this.reverse = false, + this.animationDuration = const Duration(milliseconds: 300), + }); + + @override + State createState() => + _KeyboardAwareScrollViewState(); +} + +class _KeyboardAwareScrollViewState extends State + with WidgetsBindingObserver { + late ScrollController _scrollController; + FocusNode? _currentFocus; + + @override + void initState() { + super.initState(); + _scrollController = widget.controller ?? ScrollController(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + if (widget.controller == null) { + _scrollController.dispose(); + } + super.dispose(); + } + + @override + void didChangeMetrics() { + super.didChangeMetrics(); + _adjustScrollPosition(); + } + + void _adjustScrollPosition() { + final focus = FocusManager.instance.primaryFocus; + if (focus != null && focus != _currentFocus) { + _currentFocus = focus; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + if (keyboardHeight > 0) { + _scrollController.animateTo( + _scrollController.offset + keyboardHeight / 2, + duration: widget.animationDuration, + curve: Curves.easeInOut, + ); + } + } + }); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + controller: _scrollController, + reverse: widget.reverse, + padding: widget.padding, + child: widget.child, + ); + } +} diff --git a/lib/shared/utils/platform_utils.dart b/lib/shared/utils/platform_utils.dart new file mode 100644 index 0000000..58f29f6 --- /dev/null +++ b/lib/shared/utils/platform_utils.dart @@ -0,0 +1,487 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/cupertino.dart'; +import 'dart:io' show Platform; +import '../theme/theme_extensions.dart'; + +/// Platform-specific utilities for enhanced user experience +class PlatformUtils { + PlatformUtils._(); + + /// Check if device supports haptic feedback + static bool get supportsHaptics => Platform.isIOS || Platform.isAndroid; + + /// Trigger light haptic feedback + static void lightHaptic() { + if (supportsHaptics) { + HapticFeedback.lightImpact(); + } + } + + /// Trigger medium haptic feedback + static void mediumHaptic() { + if (supportsHaptics && Platform.isIOS) { + HapticFeedback.mediumImpact(); + } else if (Platform.isAndroid) { + HapticFeedback.lightImpact(); + } + } + + /// Trigger heavy haptic feedback + static void heavyHaptic() { + if (supportsHaptics && Platform.isIOS) { + HapticFeedback.heavyImpact(); + } else if (Platform.isAndroid) { + HapticFeedback.vibrate(); + } + } + + /// Trigger selection haptic feedback + static void selectionHaptic() { + if (supportsHaptics) { + HapticFeedback.selectionClick(); + } + } + + /// Get platform-appropriate icon + static IconData getIcon({required IconData ios, required IconData android}) { + return Platform.isIOS ? ios : android; + } + + /// Get platform-appropriate text style + static TextStyle getPlatformTextStyle(BuildContext context) { + if (Platform.isIOS) { + return CupertinoTheme.of(context).textTheme.textStyle; + } + return Theme.of(context).textTheme.bodyMedium ?? const TextStyle(); + } + + /// Create platform-specific button + static Widget createButton({ + required String text, + required VoidCallback? onPressed, + bool isPrimary = true, + Color? color, + }) { + if (Platform.isIOS) { + return Builder( + builder: (context) => CupertinoButton( + onPressed: onPressed, + color: isPrimary + ? (color ?? context.conduitTheme.buttonPrimary) + : null, + child: Text(text), + ), + ); + } + + return isPrimary + ? FilledButton( + onPressed: onPressed, + style: color != null + ? FilledButton.styleFrom(backgroundColor: color) + : null, + child: Text(text), + ) + : OutlinedButton(onPressed: onPressed, child: Text(text)); + } + + /// Create platform-specific switch + static Widget createSwitch({ + required bool value, + required ValueChanged? onChanged, + Color? activeColor, + }) { + if (Platform.isIOS) { + return Builder( + builder: (context) => CupertinoSwitch( + value: value, + onChanged: onChanged, + thumbColor: activeColor ?? context.conduitTheme.buttonPrimary, + ), + ); + } + + return Switch( + value: value, + onChanged: onChanged, + activeTrackColor: activeColor, + ); + } + + /// Create platform-specific slider + static Widget createSlider({ + required double value, + required ValueChanged? onChanged, + double min = 0.0, + double max = 1.0, + int? divisions, + Color? activeColor, + }) { + if (Platform.isIOS) { + return Builder( + builder: (context) => CupertinoSlider( + value: value, + onChanged: onChanged, + min: min, + max: max, + divisions: divisions, + activeColor: activeColor ?? context.conduitTheme.buttonPrimary, + ), + ); + } + + return Slider( + value: value, + onChanged: onChanged, + min: min, + max: max, + divisions: divisions, + activeColor: activeColor, + ); + } +} + +/// iOS-specific enhancements +class IOSEnhancements { + /// Create iOS-style navigation bar + static PreferredSizeWidget createNavigationBar({ + required String title, + VoidCallback? onBack, + List? actions, + Color? backgroundColor, + }) { + return CupertinoNavigationBar( + middle: Text(title), + leading: onBack != null + ? CupertinoNavigationBarBackButton(onPressed: onBack) + : null, + trailing: actions != null && actions.isNotEmpty + ? Row(mainAxisSize: MainAxisSize.min, children: actions) + : null, + backgroundColor: backgroundColor, + ); + } + + /// Create iOS-style context menu + static Widget createContextMenu({ + required Widget child, + required List actions, + }) { + return CupertinoContextMenu( + actions: actions + .map( + (action) => CupertinoContextMenuAction( + onPressed: action.onPressed, + isDefaultAction: action.isDefault, + isDestructiveAction: action.isDestructive, + child: Text(action.title), + ), + ) + .toList(), + child: child, + ); + } + + /// Create iOS-style action sheet + static void showActionSheet({ + required BuildContext context, + required String title, + String? message, + required List actions, + }) { + showCupertinoModalPopup( + context: context, + builder: (context) => CupertinoActionSheet( + title: Text(title), + message: message != null ? Text(message) : null, + actions: actions + .map( + (action) => CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + action.onPressed(); + }, + isDefaultAction: action.isDefault, + isDestructiveAction: action.isDestructive, + child: Text(action.title), + ), + ) + .toList(), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ), + ); + } +} + +/// Android-specific enhancements +class AndroidEnhancements { + /// Create Material You themed button + static Widget createMaterial3Button({ + required String text, + required VoidCallback? onPressed, + ButtonType type = ButtonType.filled, + IconData? icon, + }) { + Widget button; + + switch (type) { + case ButtonType.filled: + button = icon != null + ? FilledButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(text), + ) + : FilledButton(onPressed: onPressed, child: Text(text)); + break; + case ButtonType.outlined: + button = icon != null + ? OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(text), + ) + : OutlinedButton(onPressed: onPressed, child: Text(text)); + break; + case ButtonType.text: + button = icon != null + ? TextButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(text), + ) + : TextButton(onPressed: onPressed, child: Text(text)); + break; + } + + return button; + } + + /// Create Material 3 card + static Widget createCard({ + required Widget child, + VoidCallback? onTap, + EdgeInsetsGeometry? padding, + CardType type = CardType.filled, + }) { + Widget card; + + switch (type) { + case CardType.filled: + card = Card.filled( + child: padding != null + ? Padding(padding: padding, child: child) + : child, + ); + break; + case CardType.outlined: + card = Card.outlined( + child: padding != null + ? Padding(padding: padding, child: child) + : child, + ); + break; + case CardType.elevated: + card = Card( + child: padding != null + ? Padding(padding: padding, child: child) + : child, + ); + break; + } + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: card, + ); + } + + return card; + } + + /// Create floating action button with Material 3 styling + static Widget createFAB({ + required VoidCallback onPressed, + required Widget child, + bool isExtended = false, + String? label, + }) { + if (isExtended && label != null) { + return FloatingActionButton.extended( + onPressed: onPressed, + icon: child, + label: Text(label), + ); + } + + return FloatingActionButton(onPressed: onPressed, child: child); + } +} + +/// Platform-aware widget that provides different implementations +class PlatformWidget extends StatelessWidget { + final Widget ios; + final Widget android; + final Widget? fallback; + + const PlatformWidget({ + super.key, + required this.ios, + required this.android, + this.fallback, + }); + + @override + Widget build(BuildContext context) { + if (Platform.isIOS) { + return ios; + } else if (Platform.isAndroid) { + return android; + } else { + return fallback ?? android; + } + } +} + +/// Enhanced button with platform-specific haptics +class HapticButton extends StatelessWidget { + final Widget child; + final VoidCallback? onPressed; + final HapticType hapticType; + final ButtonStyle? style; + + const HapticButton({ + super.key, + required this.child, + required this.onPressed, + this.hapticType = HapticType.light, + this.style, + }); + + @override + Widget build(BuildContext context) { + return FilledButton( + onPressed: onPressed != null + ? () { + _triggerHaptic(); + onPressed!(); + } + : null, + style: style, + child: child, + ); + } + + void _triggerHaptic() { + switch (hapticType) { + case HapticType.light: + PlatformUtils.lightHaptic(); + break; + case HapticType.medium: + PlatformUtils.mediumHaptic(); + break; + case HapticType.heavy: + PlatformUtils.heavyHaptic(); + break; + case HapticType.selection: + PlatformUtils.selectionHaptic(); + break; + } + } +} + +/// Enhanced list tile with platform-specific styling +class PlatformListTile extends StatelessWidget { + final Widget? leading; + final Widget? title; + final Widget? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + final bool enableHaptic; + + const PlatformListTile({ + super.key, + this.leading, + this.title, + this.subtitle, + this.trailing, + this.onTap, + this.enableHaptic = true, + }); + + @override + Widget build(BuildContext context) { + final tile = ListTile( + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + onTap: onTap != null && enableHaptic + ? () { + PlatformUtils.selectionHaptic(); + onTap!(); + } + : onTap, + ); + + if (Platform.isIOS) { + return Builder( + builder: (context) => Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + border: Border( + bottom: BorderSide( + color: context.conduitTheme.dividerColor, + width: 0.5, + ), + ), + ), + child: tile, + ), + ); + } + + return tile; + } +} + +// Enums and supporting classes +enum HapticType { light, medium, heavy, selection } + +enum ButtonType { filled, outlined, text } + +enum CardType { filled, outlined, elevated } + +class ContextMenuAction { + final String title; + final VoidCallback onPressed; + final bool isDefault; + final bool isDestructive; + + const ContextMenuAction({ + required this.title, + required this.onPressed, + this.isDefault = false, + this.isDestructive = false, + }); +} + +class ActionSheetAction { + final String title; + final VoidCallback onPressed; + final bool isDefault; + final bool isDestructive; + + const ActionSheetAction({ + required this.title, + required this.onPressed, + this.isDefault = false, + this.isDestructive = false, + }); +} diff --git a/lib/shared/utils/ui_utils.dart b/lib/shared/utils/ui_utils.dart new file mode 100644 index 0000000..33ba3ed --- /dev/null +++ b/lib/shared/utils/ui_utils.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'dart:io' show Platform; +import '../theme/theme_extensions.dart'; + +/// Utility functions for common UI patterns and helpers +/// Following Conduit design principles + +class UiUtils { + static bool get isIOS => Platform.isIOS; + + /// Returns platform-appropriate icon + static IconData platformIcon({ + required IconData ios, + required IconData android, + }) { + return isIOS ? ios : android; + } + + /// Common platform icons used throughout the app + static IconData get chatIcon => + platformIcon(ios: CupertinoIcons.chat_bubble_2, android: Icons.chat); + + static IconData get searchIcon => + platformIcon(ios: CupertinoIcons.search, android: Icons.search); + + static IconData get deleteIcon => + platformIcon(ios: CupertinoIcons.delete, android: Icons.delete); + + static IconData get archiveIcon => + platformIcon(ios: CupertinoIcons.archivebox, android: Icons.archive); + + static IconData get shareIcon => + platformIcon(ios: CupertinoIcons.share, android: Icons.share); + + static IconData get settingsIcon => + platformIcon(ios: CupertinoIcons.gear, android: Icons.settings); + + static IconData get editIcon => + platformIcon(ios: CupertinoIcons.pencil, android: Icons.edit_outlined); + + static IconData get menuIcon => + platformIcon(ios: CupertinoIcons.line_horizontal_3, android: Icons.menu); + + static IconData get addIcon => + platformIcon(ios: CupertinoIcons.plus_circle, android: Icons.add); + + static IconData get attachIcon => + platformIcon(ios: CupertinoIcons.paperclip, android: Icons.attach_file); + + static IconData get micIcon => + platformIcon(ios: CupertinoIcons.mic, android: Icons.mic); + + static IconData get sendIcon => platformIcon( + ios: CupertinoIcons.arrow_up_circle_fill, + android: Icons.send, + ); + + static IconData get moreIcon => platformIcon( + ios: CupertinoIcons.ellipsis_vertical, + android: Icons.more_vert, + ); + + static IconData get closeIcon => + platformIcon(ios: CupertinoIcons.xmark, android: Icons.close); + + static IconData get checkIcon => + platformIcon(ios: CupertinoIcons.check_mark, android: Icons.check); + + static IconData get globeIcon => + platformIcon(ios: CupertinoIcons.globe, android: Icons.public); + + static IconData get folderIcon => + platformIcon(ios: CupertinoIcons.folder, android: Icons.folder); + + static IconData get tagIcon => + platformIcon(ios: CupertinoIcons.tag, android: Icons.label); + + static IconData get copyIcon => + platformIcon(ios: CupertinoIcons.doc_on_doc, android: Icons.copy); + + static IconData get pinIcon => + platformIcon(ios: CupertinoIcons.pin_fill, android: Icons.push_pin); + + static IconData get unpinIcon => platformIcon( + ios: CupertinoIcons.pin_slash, + android: Icons.push_pin_outlined, + ); + + /// Shows a Conduit-styled snackbar with conversational messaging + static void showMessage( + BuildContext context, + String message, { + bool isError = false, + VoidCallback? onRetry, + Duration? duration, + }) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError + ? context.conduitTheme.error + : context.conduitTheme.buttonPrimary, + behavior: SnackBarBehavior.floating, + action: onRetry != null + ? SnackBarAction( + label: 'Try again', + textColor: context.conduitTheme.textInverse, + onPressed: onRetry, + ) + : null, + duration: duration ?? const Duration(seconds: 3), + ), + ); + } + + /// Shows a Conduit-styled confirmation dialog + static Future showConfirmationDialog( + BuildContext context, { + required String title, + required String message, + String confirmText = 'Confirm', + String cancelText = 'Cancel', + bool isDestructive = false, + }) async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: context.conduitTheme.surfaceBackground, + title: Text( + title, + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + content: Text( + message, + style: TextStyle(color: context.conduitTheme.textSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(cancelText), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: isDestructive + ? TextButton.styleFrom( + foregroundColor: context.conduitTheme.error, + ) + : null, + child: Text(confirmText), + ), + ], + ), + ) ?? + false; + } + + /// Formats dates in a conversational way following Conduit patterns + static String formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + if (difference.inHours == 0) { + if (difference.inMinutes == 0) { + return 'Just now'; + } + return '${difference.inMinutes}m ago'; + } + return '${difference.inHours}h ago'; + } else if (difference.inDays == 1) { + return 'Yesterday'; + } else if (difference.inDays < 7) { + return '${difference.inDays} days ago'; + } else if (difference.inDays < 30) { + final weeks = (difference.inDays / 7).floor(); + return weeks == 1 ? '1 week ago' : '$weeks weeks ago'; + } else if (difference.inDays < 365) { + final months = (difference.inDays / 30).floor(); + return months == 1 ? '1 month ago' : '$months months ago'; + } else { + return '${date.month}/${date.day}/${date.year}'; + } + } + + /// Creates a smooth haptic feedback on iOS + static void hapticFeedback() { + if (isIOS) { + // iOS haptic feedback would be implemented here + // For now, we'll leave this as a placeholder + } + } + + /// Safe area padding helper + static EdgeInsets safeAreaPadding(BuildContext context) { + return MediaQuery.of(context).padding; + } + + /// Screen size helpers + static Size screenSize(BuildContext context) { + return MediaQuery.of(context).size; + } + + static bool isSmallScreen(BuildContext context) { + return screenSize(context).width < 375; + } + + static bool isLargeScreen(BuildContext context) { + return screenSize(context).width > 414; + } + + /// Keyboard handling + static bool isKeyboardOpen(BuildContext context) { + return MediaQuery.of(context).viewInsets.bottom > 0; + } + + /// Focus management + static void unfocus(BuildContext context) { + FocusScope.of(context).unfocus(); + } +} diff --git a/lib/shared/widgets/cached_image.dart b/lib/shared/widgets/cached_image.dart new file mode 100644 index 0000000..d31b9af --- /dev/null +++ b/lib/shared/widgets/cached_image.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../theme/theme_extensions.dart'; +import 'improved_loading_states.dart'; + +/// Cached network image widget with progressive loading and error handling +class CachedImage extends StatelessWidget { + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + final Widget? placeholder; + final Widget? errorWidget; + final Duration fadeInDuration; + final Duration fadeOutDuration; + final bool enableMemoryCache; + final int? maxWidthDiskCache; + final int? maxHeightDiskCache; + + const CachedImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.placeholder, + this.errorWidget, + this.fadeInDuration = const Duration(milliseconds: 300), + this.fadeOutDuration = const Duration(milliseconds: 100), + this.enableMemoryCache = true, + this.maxWidthDiskCache, + this.maxHeightDiskCache, + }); + + @override + Widget build(BuildContext context) { + return CachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: fit, + fadeInDuration: fadeInDuration, + fadeOutDuration: fadeOutDuration, + placeholder: placeholder != null + ? (context, url) => placeholder! + : _buildDefaultPlaceholder, + errorWidget: errorWidget != null + ? (context, url, error) => errorWidget! + : _buildDefaultErrorWidget, + memCacheWidth: enableMemoryCache ? width?.toInt() : null, + memCacheHeight: enableMemoryCache ? height?.toInt() : null, + maxWidthDiskCache: maxWidthDiskCache, + maxHeightDiskCache: maxHeightDiskCache, + useOldImageOnUrlChange: true, + filterQuality: FilterQuality.medium, + ); + } + + Widget _buildDefaultPlaceholder(BuildContext context, String url) { + return ShimmerLoader( + width: width ?? double.infinity, + height: height ?? 200, + borderRadius: BorderRadius.circular(8), + ); + } + + Widget _buildDefaultErrorWidget( + BuildContext context, + String url, + dynamic error, + ) { + return Container( + width: width, + height: height, + color: context.conduitTheme.shimmerBase, + child: Icon( + Icons.broken_image, + color: context.conduitTheme.iconSecondary, + size: (width != null && height != null) + ? (width! < height! ? width! * 0.5 : height! * 0.5) + : 24, + ), + ); + } +} + +/// Cached circular avatar with progressive loading +class CachedAvatar extends StatelessWidget { + final String? imageUrl; + final String fallbackText; + final double radius; + final Color? backgroundColor; + final Color? textColor; + + const CachedAvatar({ + super.key, + this.imageUrl, + required this.fallbackText, + this.radius = 20, + this.backgroundColor, + this.textColor, + }); + + @override + Widget build(BuildContext context) { + return CircleAvatar( + radius: radius, + backgroundColor: + backgroundColor ?? context.conduitTheme.surfaceBackground, + child: imageUrl != null + ? ClipOval( + child: CachedNetworkImage( + imageUrl: imageUrl!, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, + placeholder: (context, url) => CircularProgressIndicator( + strokeWidth: 2, + color: textColor ?? context.conduitTheme.iconSecondary, + ), + errorWidget: (context, url, error) => Text( + fallbackText.isNotEmpty ? fallbackText[0].toUpperCase() : '?', + style: TextStyle( + color: textColor ?? context.conduitTheme.textPrimary, + fontWeight: FontWeight.bold, + fontSize: radius * 0.6, + ), + ), + memCacheWidth: (radius * 2).toInt(), + memCacheHeight: (radius * 2).toInt(), + ), + ) + : Text( + fallbackText.isNotEmpty ? fallbackText[0].toUpperCase() : '?', + style: TextStyle( + color: textColor ?? context.conduitTheme.textPrimary, + fontWeight: FontWeight.bold, + fontSize: radius * 0.6, + ), + ), + ); + } +} diff --git a/lib/shared/widgets/conduit_components.dart b/lib/shared/widgets/conduit_components.dart new file mode 100644 index 0000000..1ead263 --- /dev/null +++ b/lib/shared/widgets/conduit_components.dart @@ -0,0 +1,958 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../theme/theme_extensions.dart'; +import '../services/brand_service.dart'; +import '../../core/services/enhanced_accessibility_service.dart'; +import '../../core/services/platform_service.dart'; +import '../../core/services/settings_service.dart'; + +/// Unified component library following Conduit design patterns +/// This provides consistent, reusable UI components throughout the app + +class ConduitButton extends ConsumerWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final bool isDestructive; + final bool isSecondary; + final IconData? icon; + final double? width; + final bool isFullWidth; + final bool isCompact; + + const ConduitButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.isDestructive = false, + this.isSecondary = false, + this.icon, + this.width, + this.isFullWidth = false, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hapticEnabled = ref.watch(hapticEnabledProvider); + Color backgroundColor; + Color textColor; + + if (isDestructive) { + backgroundColor = context.conduitTheme.error; + textColor = context.conduitTheme.buttonPrimaryText; + } else if (isSecondary) { + backgroundColor = context.conduitTheme.buttonSecondary; + textColor = context.conduitTheme.buttonSecondaryText; + } else { + backgroundColor = context.conduitTheme.buttonPrimary; + textColor = context.conduitTheme.buttonPrimaryText; + } + + // Build semantic label + String semanticLabel = text; + if (isLoading) { + semanticLabel = 'Loading: $text'; + } else if (isDestructive) { + semanticLabel = 'Warning: $text'; + } + + return Semantics( + label: semanticLabel, + button: true, + enabled: !isLoading && onPressed != null, + child: SizedBox( + width: isFullWidth ? double.infinity : width, + height: isCompact ? TouchTarget.medium : TouchTarget.comfortable, + child: ElevatedButton( + onPressed: isLoading + ? null + : () { + if (onPressed != null) { + PlatformService.hapticFeedbackWithSettings( + type: isDestructive + ? HapticType.warning + : HapticType.light, + hapticEnabled: hapticEnabled, + ); + onPressed!(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: backgroundColor, + foregroundColor: textColor, + disabledBackgroundColor: context.conduitTheme.buttonDisabled, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.button), + ), + elevation: Elevation.none, + shadowColor: backgroundColor.withValues(alpha: Alpha.standard), + minimumSize: Size( + TouchTarget.minimum, + isCompact ? TouchTarget.medium : TouchTarget.comfortable, + ), + padding: EdgeInsets.symmetric( + horizontal: isCompact ? Spacing.md : Spacing.buttonPadding, + vertical: isCompact ? Spacing.sm : Spacing.sm, + ), + ), + child: isLoading + ? Semantics( + label: 'Loading', + excludeSemantics: true, + child: SizedBox( + width: IconSize.small, + height: IconSize.small, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(textColor), + ), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: IconSize.small), + SizedBox(width: Spacing.iconSpacing), + ], + Flexible( + child: EnhancedAccessibilityService.createAccessibleText( + text, + style: AppTypography.standard.copyWith( + fontWeight: FontWeight.w600, + color: textColor, + ), + maxLines: 1, + ), + ), + ], + ), + ), + ), + ); + } +} + +class ConduitInput extends StatelessWidget { + final String? label; + final String? hint; + final TextEditingController? controller; + final ValueChanged? onChanged; + final VoidCallback? onTap; + final bool obscureText; + final bool enabled; + final String? errorText; + final int? maxLines; + final Widget? suffixIcon; + final Widget? prefixIcon; + final TextInputType? keyboardType; + final bool autofocus; + final String? semanticLabel; + final ValueChanged? onSubmitted; + final bool isRequired; + + const ConduitInput({ + super.key, + this.label, + this.hint, + this.controller, + this.onChanged, + this.onTap, + this.obscureText = false, + this.enabled = true, + this.errorText, + this.maxLines = 1, + this.suffixIcon, + this.prefixIcon, + this.keyboardType, + this.autofocus = false, + this.semanticLabel, + this.onSubmitted, + this.isRequired = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Row( + children: [ + Text( + label!, + style: AppTypography.standard.copyWith( + fontWeight: FontWeight.w500, + color: context.conduitTheme.textPrimary, + ), + ), + if (isRequired) ...[ + SizedBox(width: Spacing.textSpacing), + Text( + '*', + style: AppTypography.standard.copyWith( + color: context.conduitTheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + SizedBox(height: Spacing.sm), + ], + Semantics( + label: semanticLabel ?? label ?? 'Input field', + textField: true, + child: TextField( + controller: controller, + onChanged: onChanged, + onTap: onTap, + onSubmitted: onSubmitted, + obscureText: obscureText, + enabled: enabled, + maxLines: maxLines, + keyboardType: keyboardType, + autofocus: autofocus, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textPrimary, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: AppTypography.standard.copyWith( + color: context.conduitTheme.inputPlaceholder, + ), + filled: true, + fillColor: context.conduitTheme.inputBackground, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + width: BorderWidth.standard, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + width: BorderWidth.standard, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.buttonPrimary, + width: BorderWidth.thick, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.error, + width: BorderWidth.standard, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.error, + width: BorderWidth.thick, + ), + ), + contentPadding: EdgeInsets.symmetric( + horizontal: Spacing.inputPadding, + vertical: Spacing.md, + ), + suffixIcon: suffixIcon, + prefixIcon: prefixIcon, + errorText: errorText, + errorStyle: AppTypography.small.copyWith( + color: context.conduitTheme.error, + ), + ), + ), + ), + ], + ); + } +} + +class ConduitCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + final VoidCallback? onTap; + final bool isSelected; + final bool isElevated; + final bool isCompact; + + const ConduitCard({ + super.key, + required this.child, + this.padding, + this.onTap, + this.isSelected = false, + this.isElevated = false, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: + padding ?? + EdgeInsets.all(isCompact ? Spacing.md : Spacing.cardPadding), + decoration: BoxDecoration( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.highlight, + ) + : context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.standard, + ) + : context.conduitTheme.cardBorder, + width: BorderWidth.standard, + ), + boxShadow: isElevated ? ConduitShadows.card : null, + ), + child: child, + ), + ); + } +} + +class ConduitIconButton extends ConsumerWidget { + final IconData icon; + final VoidCallback? onPressed; + final String? tooltip; + final bool isActive; + final Color? backgroundColor; + final Color? iconColor; + final bool isCompact; + final bool isCircular; + + const ConduitIconButton({ + super.key, + required this.icon, + this.onPressed, + this.tooltip, + this.isActive = false, + this.backgroundColor, + this.iconColor, + this.isCompact = false, + this.isCircular = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hapticEnabled = ref.watch(hapticEnabledProvider); + final effectiveBackgroundColor = + backgroundColor ?? + (isActive + ? context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.highlight, + ) + : Colors.transparent); + final effectiveIconColor = + iconColor ?? + (isActive + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.iconSecondary); + + // Build semantic label with context + String semanticLabel = tooltip ?? 'Button'; + if (isActive) { + semanticLabel = '$semanticLabel, active'; + } + + return Semantics( + label: semanticLabel, + button: true, + enabled: onPressed != null, + child: Tooltip( + message: tooltip ?? '', + child: GestureDetector( + onTap: () { + if (onPressed != null) { + PlatformService.hapticFeedbackWithSettings( + type: HapticType.selection, + hapticEnabled: hapticEnabled, + ); + onPressed!(); + } + }, + child: Container( + width: isCompact ? TouchTarget.medium : TouchTarget.minimum, + height: isCompact ? TouchTarget.medium : TouchTarget.minimum, + decoration: BoxDecoration( + color: effectiveBackgroundColor, + borderRadius: BorderRadius.circular( + isCircular + ? AppBorderRadius.circular + : AppBorderRadius.standard, + ), + border: isActive + ? Border.all( + color: context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.standard, + ), + width: BorderWidth.standard, + ) + : null, + ), + child: Icon( + icon, + size: isCompact ? IconSize.small : IconSize.medium, + color: effectiveIconColor, + semanticLabel: tooltip, + ), + ), + ), + ), + ); + } +} + +class ConduitLoadingIndicator extends StatelessWidget { + final String? message; + final double size; + final bool isCompact; + + const ConduitLoadingIndicator({ + super.key, + this.message, + this.size = 24, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: isCompact ? 2 : 3, + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.buttonPrimary, + ), + ), + ), + if (message != null) ...[ + SizedBox(height: isCompact ? Spacing.sm : Spacing.md), + Text( + message!, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ], + ); + } +} + +class ConduitEmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String message; + final Widget? action; + final bool isCompact; + + const ConduitEmptyState({ + super.key, + required this.icon, + required this.title, + required this.message, + this.action, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: isCompact ? IconSize.xxl : IconSize.xxl + Spacing.md, + height: isCompact ? IconSize.xxl : IconSize.xxl + Spacing.md, + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.circular), + ), + child: Icon( + icon, + size: isCompact ? IconSize.xl : TouchTarget.minimum, + color: context.conduitTheme.iconSecondary, + ), + ), + SizedBox(height: isCompact ? Spacing.sm : Spacing.md), + Text( + title, + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: Spacing.sm), + Text( + message, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + if (action != null) ...[ + SizedBox(height: isCompact ? Spacing.md : Spacing.lg), + action!, + ], + ], + ), + ), + ); + } +} + +class ConduitAvatar extends StatelessWidget { + final double size; + final IconData? icon; + final String? text; + final bool isCompact; + + const ConduitAvatar({ + super.key, + this.size = 32, + this.icon, + this.text, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return BrandService.createBrandAvatar( + size: isCompact ? size * 0.8 : size, + fallbackText: text, + ); + } +} + +class ConduitBadge extends StatelessWidget { + final String text; + final Color? backgroundColor; + final Color? textColor; + final bool isCompact; + + const ConduitBadge({ + super.key, + required this.text, + this.backgroundColor, + this.textColor, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: isCompact ? Spacing.sm : Spacing.md, + vertical: isCompact ? Spacing.xs : Spacing.sm, + ), + decoration: BoxDecoration( + color: + backgroundColor ?? + context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.badgeBackground, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.badge), + ), + child: Text( + text, + style: AppTypography.small.copyWith( + color: textColor ?? context.conduitTheme.buttonPrimary, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class ConduitChip extends StatelessWidget { + final String label; + final VoidCallback? onTap; + final bool isSelected; + final IconData? icon; + final bool isCompact; + + const ConduitChip({ + super.key, + required this.label, + this.onTap, + this.isSelected = false, + this.icon, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: isCompact ? Spacing.sm : Spacing.md, + vertical: isCompact ? Spacing.xs : Spacing.sm, + ), + decoration: BoxDecoration( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.highlight, + ) + : context.conduitTheme.surfaceContainer, + borderRadius: BorderRadius.circular(AppBorderRadius.chip), + border: Border.all( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.standard, + ) + : context.conduitTheme.dividerColor, + width: BorderWidth.standard, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: isCompact ? IconSize.xs : IconSize.small, + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.iconSecondary, + ), + SizedBox(width: Spacing.iconSpacing), + ], + Text( + label, + style: AppTypography.small.copyWith( + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} + +class ConduitDivider extends StatelessWidget { + final bool isCompact; + final Color? color; + + const ConduitDivider({super.key, this.isCompact = false, this.color}); + + @override + Widget build(BuildContext context) { + return Container( + height: BorderWidth.standard, + color: color ?? context.conduitTheme.dividerColor, + margin: EdgeInsets.symmetric( + vertical: isCompact ? Spacing.sm : Spacing.md, + ), + ); + } +} + +class ConduitSpacer extends StatelessWidget { + final double height; + final bool isCompact; + + const ConduitSpacer({super.key, this.height = 16, this.isCompact = false}); + + @override + Widget build(BuildContext context) { + return SizedBox(height: isCompact ? height * 0.5 : height); + } +} + +/// Enhanced form field with better accessibility and validation +class AccessibleFormField extends StatelessWidget { + final String? label; + final String? hint; + final TextEditingController? controller; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onTap; + final bool obscureText; + final bool enabled; + final String? errorText; + final int? maxLines; + final Widget? suffixIcon; + final Widget? prefixIcon; + final TextInputType? keyboardType; + final bool autofocus; + final String? semanticLabel; + final String? Function(String?)? validator; + final bool isRequired; + final bool isCompact; + + const AccessibleFormField({ + super.key, + this.label, + this.hint, + this.controller, + this.onChanged, + this.onSubmitted, + this.onTap, + this.obscureText = false, + this.enabled = true, + this.errorText, + this.maxLines = 1, + this.suffixIcon, + this.prefixIcon, + this.keyboardType, + this.autofocus = false, + this.semanticLabel, + this.validator, + this.isRequired = false, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Row( + children: [ + Text( + label!, + style: AppTypography.standard.copyWith( + fontWeight: FontWeight.w500, + color: context.conduitTheme.textPrimary, + ), + ), + if (isRequired) ...[ + SizedBox(width: Spacing.textSpacing), + Text( + '*', + style: AppTypography.standard.copyWith( + color: context.conduitTheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + SizedBox(height: isCompact ? Spacing.xs : Spacing.sm), + ], + Semantics( + label: semanticLabel ?? label ?? 'Input field', + textField: true, + child: TextFormField( + controller: controller, + onChanged: onChanged, + onTap: onTap, + onFieldSubmitted: onSubmitted, + obscureText: obscureText, + enabled: enabled, + maxLines: maxLines, + keyboardType: keyboardType, + autofocus: autofocus, + validator: validator, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textPrimary, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: AppTypography.standard.copyWith( + color: context.conduitTheme.inputPlaceholder, + ), + filled: true, + fillColor: context.conduitTheme.inputBackground, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + width: BorderWidth.standard, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + width: BorderWidth.standard, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.buttonPrimary, + width: BorderWidth.thick, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.error, + width: BorderWidth.standard, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + borderSide: BorderSide( + color: context.conduitTheme.error, + width: BorderWidth.thick, + ), + ), + contentPadding: EdgeInsets.symmetric( + horizontal: isCompact ? Spacing.md : Spacing.inputPadding, + vertical: isCompact ? Spacing.sm : Spacing.md, + ), + suffixIcon: suffixIcon, + prefixIcon: prefixIcon, + errorText: errorText, + errorStyle: AppTypography.small.copyWith( + color: context.conduitTheme.error, + ), + ), + ), + ), + ], + ); + } +} + +/// Enhanced section header with better typography +class ConduitSectionHeader extends StatelessWidget { + final String title; + final String? subtitle; + final Widget? action; + final bool isCompact; + + const ConduitSectionHeader({ + super.key, + required this.title, + this.subtitle, + this.action, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: isCompact ? Spacing.md : Spacing.pagePadding, + vertical: isCompact ? Spacing.sm : Spacing.md, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) ...[ + SizedBox(height: Spacing.textSpacing), + Text( + subtitle!, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ], + ), + ), + if (action != null) ...[SizedBox(width: Spacing.md), action!], + ], + ), + ); + } +} + +/// Enhanced list item with better consistency +class ConduitListItem extends StatelessWidget { + final Widget leading; + final Widget title; + final Widget? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + final bool isSelected; + final bool isCompact; + + const ConduitListItem({ + super.key, + required this.leading, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + this.isSelected = false, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: EdgeInsets.all( + isCompact ? Spacing.sm : Spacing.listItemPadding, + ), + decoration: BoxDecoration( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.highlight, + ) + : Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.standard), + ), + child: Row( + children: [ + leading, + SizedBox(width: isCompact ? Spacing.sm : Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + title, + if (subtitle != null) ...[ + SizedBox(height: Spacing.textSpacing), + subtitle!, + ], + ], + ), + ), + if (trailing != null) ...[ + SizedBox(width: isCompact ? Spacing.sm : Spacing.md), + trailing!, + ], + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/empty_states.dart b/lib/shared/widgets/empty_states.dart new file mode 100644 index 0000000..6eb415e --- /dev/null +++ b/lib/shared/widgets/empty_states.dart @@ -0,0 +1,448 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:io' show Platform; +import '../theme/theme_extensions.dart'; + +import '../services/brand_service.dart'; + +/// Enhanced empty state widgets with illustrations and actions +class ConduitEmptyState extends StatelessWidget { + final String title; + final String? subtitle; + final IconData? icon; + final Widget? illustration; + final List? actions; + final bool isLoading; + + const ConduitEmptyState({ + super.key, + required this.title, + this.subtitle, + this.icon, + this.illustration, + this.actions, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + final conduitTheme = context.conduitTheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.xl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Illustration or icon + if (illustration != null) + illustration! + else if (icon != null) + Container( + width: IconSize.xxl * 2.5, // 120px equivalent + height: IconSize.xxl * 2.5, // 120px equivalent + decoration: BoxDecoration( + color: conduitTheme.cardBackground, + shape: BoxShape.circle, + border: Border.all(color: conduitTheme.cardBorder, width: 2), + ), + child: Icon( + icon!, + size: IconSize.xxl, + color: context.conduitTheme.iconSecondary, + ), + ) + else + // Default to brand icon when no specific icon or illustration provided + BrandService.createBrandEmptyStateIcon( + size: IconSize.xxl * 2.5, // 120px equivalent + showBackground: true, + ), + + const SizedBox(height: Spacing.xl), + + // Title + Text( + title, + style: conduitTheme.headingMedium, + textAlign: TextAlign.center, + ), + + // Subtitle + if (subtitle != null) ...[ + const SizedBox(height: Spacing.xs), + Text( + subtitle!, + style: conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + + // Actions + if (actions != null && actions!.isNotEmpty) ...[ + const SizedBox(height: Spacing.xl), + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: _buildActionButton(context, action), + ), + ), + ], + ], + ), + ), + ).animate().fadeIn( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + Widget _buildActionButton(BuildContext context, EmptyStateAction action) { + return SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: action.onPressed, + style: action.isPrimary + ? FilledButton.styleFrom( + backgroundColor: context.conduitTheme.buttonPrimary, + foregroundColor: context.conduitTheme.buttonPrimaryText, + ) + : FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: context.conduitTheme.textSecondary, + side: BorderSide( + color: context.conduitTheme.dividerColor, + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (action.icon != null) ...[ + Icon(action.icon, size: IconSize.md), + const SizedBox(width: Spacing.sm), + ], + Text(action.label), + ], + ), + ), + ); + } +} + +/// Action for empty states +class EmptyStateAction { + final String label; + final VoidCallback onPressed; + final IconData? icon; + final bool isPrimary; + + const EmptyStateAction({ + required this.label, + required this.onPressed, + this.icon, + this.isPrimary = true, + }); +} + +/// Chat-specific empty state +class ChatEmptyState extends StatelessWidget { + final VoidCallback? onStartChat; + + const ChatEmptyState({super.key, this.onStartChat}); + + @override + Widget build(BuildContext context) { + return ConduitEmptyState( + title: 'Start a conversation', + subtitle: + 'Ask me anything! I\'m here to help with questions, creative tasks, analysis, and more.', + // Remove custom illustration to use default brand icon + icon: BrandService.primaryIcon, + actions: onStartChat != null + ? [ + EmptyStateAction( + label: 'Start chatting', + icon: BrandService.primaryIcon, + onPressed: onStartChat!, + ), + ] + : null, + ); + } +} + +/// Files empty state +class FilesEmptyState extends StatelessWidget { + final VoidCallback? onUploadFile; + + const FilesEmptyState({super.key, this.onUploadFile}); + + @override + Widget build(BuildContext context) { + return ConduitEmptyState( + title: 'No files yet', + subtitle: + 'Upload documents, images, or other files to get started with your knowledge base.', + illustration: Builder( + builder: (context) => _buildFilesIllustration(context), + ), + actions: onUploadFile != null + ? [ + EmptyStateAction( + label: 'Upload files', + icon: Platform.isIOS + ? CupertinoIcons.doc_on_doc + : Icons.upload_file, + onPressed: onUploadFile!, + ), + ] + : null, + ); + } + + Widget _buildFilesIllustration(BuildContext context) { + return SizedBox( + width: 120, + height: 120, + child: Stack( + alignment: Alignment.center, + children: [ + // Background circle + Container( + width: IconSize.xxl * 2.5, // 120px equivalent + height: IconSize.xxl * 2.5, // 120px equivalent + decoration: BoxDecoration( + color: context.conduitTheme.info.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + ), + // File stack + ...List.generate(3, (index) { + return Positioned( + top: 30 + (index * 8.0), + left: 30 + (index * 4.0), + child: + Container( + width: TouchTarget.minimum, + height: 50, + decoration: BoxDecoration( + color: [ + context.conduitTheme.info, + context.conduitTheme.success, + context.conduitTheme.warning, + ][index], + borderRadius: BorderRadius.circular( + AppBorderRadius.xs, + ), + ), + child: Icon( + [Icons.description, Icons.image, Icons.folder][index], + color: context.conduitTheme.textInverse, + size: IconSize.md, + ), + ) + .animate(delay: Duration(milliseconds: index * 200)) + .fadeIn() + .slideY(begin: 0.3, end: 0), + ); + }), + ], + ), + ); + } +} + +/// Tools empty state +class ToolsEmptyState extends StatelessWidget { + final VoidCallback? onExploreTools; + + const ToolsEmptyState({super.key, this.onExploreTools}); + + @override + Widget build(BuildContext context) { + return ConduitEmptyState( + title: 'Powerful tools await', + subtitle: 'Discover tools to enhance your productivity and creativity.', + illustration: Builder( + builder: (context) => _buildToolsIllustration(context), + ), + actions: onExploreTools != null + ? [ + EmptyStateAction( + label: 'Explore tools', + icon: Platform.isIOS + ? CupertinoIcons.wand_stars + : Icons.auto_awesome, + onPressed: onExploreTools!, + ), + ] + : null, + ); + } + + Widget _buildToolsIllustration(BuildContext context) { + return SizedBox( + width: 120, + height: 120, + child: Stack( + alignment: Alignment.center, + children: [ + // Background circle + Container( + width: IconSize.xxl * 2.5, // 120px equivalent + height: IconSize.xxl * 2.5, // 120px equivalent + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + ), + // Tools arrangement + ...List.generate(6, (index) { + final angle = (index * 60) * (3.14159 / 180); + final radius = 35.0; + return Positioned( + top: 60 + (radius * -cos(angle)) - 15, + left: 60 + (radius * sin(angle)) - 15, + child: + Container( + width: Spacing.xl - Spacing.xxs, // 30px equivalent + height: Spacing.xl - Spacing.xxs, // 30px equivalent + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + shape: BoxShape.circle, + ), + child: Icon( + [ + Icons.palette, + Icons.calculate, + Icons.code, + Icons.translate, + Icons.music_note, + Icons.analytics, + ][index], + color: context.conduitTheme.textInverse, + size: IconSize.sm, + ), + ) + .animate(delay: Duration(milliseconds: index * 100)) + .fadeIn() + .scale( + begin: const Offset(0.5, 0.5), + end: const Offset(1.0, 1.0), + ), + ); + }), + ], + ), + ); + } +} + +/// Search results empty state +class SearchEmptyState extends StatelessWidget { + final String query; + final VoidCallback? onClearSearch; + + const SearchEmptyState({super.key, required this.query, this.onClearSearch}); + + @override + Widget build(BuildContext context) { + return ConduitEmptyState( + title: 'No results found', + subtitle: 'No results for "$query". Try adjusting your search terms.', + icon: Platform.isIOS ? CupertinoIcons.search : Icons.search_off, + actions: onClearSearch != null + ? [ + EmptyStateAction( + label: 'Clear search', + icon: Platform.isIOS ? CupertinoIcons.clear : Icons.clear, + onPressed: onClearSearch!, + isPrimary: false, + ), + ] + : null, + ); + } +} + +/// Connection error empty state +class ConnectionEmptyState extends StatelessWidget { + final VoidCallback? onRetry; + + const ConnectionEmptyState({super.key, this.onRetry}); + + @override + Widget build(BuildContext context) { + return ConduitEmptyState( + title: 'Connection problem', + subtitle: + 'Unable to load content. Please check your connection and try again.', + icon: Platform.isIOS ? CupertinoIcons.wifi_slash : Icons.wifi_off, + actions: onRetry != null + ? [ + EmptyStateAction( + label: 'Try again', + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + onPressed: onRetry!, + ), + ] + : null, + ); + } +} + +/// Generic empty state with custom illustration +class CustomEmptyState extends StatelessWidget { + final String title; + final String subtitle; + final Widget illustration; + final List? actions; + + const CustomEmptyState({ + super.key, + required this.title, + required this.subtitle, + required this.illustration, + this.actions, + }); + + @override + Widget build(BuildContext context) { + return ConduitEmptyState( + title: title, + subtitle: subtitle, + illustration: illustration, + actions: actions, + ); + } +} + +// Helper function to get cosine +double cos(double radians) { + // Simple cosine approximation for illustration positioning + if (radians == 0) return 1.0; + if (radians == 1.5708) return 0.0; // π/2 + if (radians == 3.14159) return -1.0; // π + if (radians == 4.71239) return 0.0; // 3π/2 + + // Taylor series approximation for other values + double x2 = radians * radians; + return 1 - x2 / 2 + x2 * x2 / 24 - x2 * x2 * x2 / 720; +} + +// Helper function to get sine +double sin(double radians) { + // Simple sine approximation for illustration positioning + if (radians == 0) return 0.0; + if (radians == 1.5708) return 1.0; // π/2 + if (radians == 3.14159) return 0.0; // π + if (radians == 4.71239) return -1.0; // 3π/2 + + // Taylor series approximation for other values + double x2 = radians * radians; + return radians - radians * x2 / 6 + radians * x2 * x2 / 120; +} diff --git a/lib/shared/widgets/error_widgets.dart b/lib/shared/widgets/error_widgets.dart new file mode 100644 index 0000000..ac2a5f1 --- /dev/null +++ b/lib/shared/widgets/error_widgets.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import '../theme/theme_extensions.dart'; +import 'conduit_components.dart'; + +/// Enhanced error widget with production-grade design and better hierarchy +class ConduitErrorWidget extends StatelessWidget { + final String title; + final String message; + final String? actionLabel; + final VoidCallback? onAction; + final IconData? icon; + final bool isCompact; + + const ConduitErrorWidget({ + super.key, + required this.title, + required this.message, + this.actionLabel, + this.onAction, + this.icon, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.cardPadding), + decoration: BoxDecoration( + color: context.conduitTheme.errorBackground.withValues( + alpha: Alpha.badgeBackground, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: context.conduitTheme.error.withValues(alpha: Alpha.subtle), + width: BorderWidth.standard, + ), + boxShadow: ConduitShadows.card, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon ?? Icons.error_outline, + size: isCompact ? IconSize.large : IconSize.xl, + color: context.conduitTheme.error, + ), + SizedBox(height: isCompact ? Spacing.sm : Spacing.md), + Text( + title, + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.error, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: isCompact ? Spacing.xs : Spacing.sm), + Text( + message, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: isCompact ? Spacing.md : Spacing.lg), + SizedBox( + width: double.infinity, + child: ConduitButton( + text: actionLabel!, + onPressed: onAction, + isDestructive: true, + isCompact: isCompact, + ), + ), + ], + ], + ), + ); + } +} + +/// Enhanced network error widget with better hierarchy +class NetworkErrorWidget extends StatelessWidget { + final VoidCallback? onRetry; + final String? customMessage; + final bool isCompact; + + const NetworkErrorWidget({ + super.key, + this.onRetry, + this.customMessage, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return ConduitErrorWidget( + title: 'Connection Error', + message: + customMessage ?? + 'Unable to connect to the server. Please check your internet connection and try again.', + actionLabel: 'Retry', + onAction: onRetry, + icon: Icons.wifi_off, + isCompact: isCompact, + ); + } +} + +/// Enhanced empty state widget with better hierarchy +class EmptyStateWidget extends StatelessWidget { + final String title; + final String message; + final IconData? icon; + final String? actionLabel; + final VoidCallback? onAction; + final bool isCompact; + + const EmptyStateWidget({ + super.key, + required this.title, + required this.message, + this.icon, + this.actionLabel, + this.onAction, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.cardPadding), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.standard, + ), + boxShadow: ConduitShadows.card, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon ?? Icons.inbox_outlined, + size: isCompact ? IconSize.large : IconSize.xxl, + color: context.conduitTheme.iconSecondary, + ), + SizedBox(height: isCompact ? Spacing.sm : Spacing.md), + Text( + title, + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: isCompact ? Spacing.xs : Spacing.sm), + Text( + message, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: isCompact ? Spacing.md : Spacing.lg), + SizedBox( + width: double.infinity, + child: ConduitButton( + text: actionLabel!, + onPressed: onAction, + isCompact: isCompact, + ), + ), + ], + ], + ), + ); + } +} + +/// Enhanced loading error widget with better hierarchy +class LoadingErrorWidget extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + final bool isCompact; + + const LoadingErrorWidget({ + super.key, + required this.message, + this.onRetry, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return ConduitErrorWidget( + title: 'Loading Failed', + message: message, + actionLabel: onRetry != null ? 'Try Again' : null, + onAction: onRetry, + icon: Icons.error_outline, + isCompact: isCompact, + ); + } +} + +/// Enhanced validation error widget with better hierarchy +class ValidationErrorWidget extends StatelessWidget { + final String fieldName; + final String message; + final VoidCallback? onFix; + final bool isCompact; + + const ValidationErrorWidget({ + super.key, + required this.fieldName, + required this.message, + this.onFix, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return ConduitErrorWidget( + title: 'Invalid $fieldName', + message: message, + actionLabel: onFix != null ? 'Fix Now' : null, + onAction: onFix, + icon: Icons.warning_amber_outlined, + isCompact: isCompact, + ); + } +} + +/// Enhanced permission error widget with better hierarchy +class PermissionErrorWidget extends StatelessWidget { + final String permission; + final String message; + final VoidCallback? onGrant; + final bool isCompact; + + const PermissionErrorWidget({ + super.key, + required this.permission, + required this.message, + this.onGrant, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return ConduitErrorWidget( + title: 'Permission Required', + message: 'This app needs $permission permission to $message.', + actionLabel: onGrant != null ? 'Grant Permission' : null, + onAction: onGrant, + icon: Icons.security, + isCompact: isCompact, + ); + } +} + +/// Enhanced server error widget with better hierarchy +class ServerErrorWidget extends StatelessWidget { + final String error; + final VoidCallback? onRetry; + final bool isCompact; + + const ServerErrorWidget({ + super.key, + required this.error, + this.onRetry, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return ConduitErrorWidget( + title: 'Server Error', + message: error, + actionLabel: onRetry != null ? 'Retry' : null, + onAction: onRetry, + icon: Icons.cloud_off, + isCompact: isCompact, + ); + } +} + +/// Enhanced file error widget with better hierarchy +class FileErrorWidget extends StatelessWidget { + final String fileName; + final String error; + final VoidCallback? onRetry; + final bool isCompact; + + const FileErrorWidget({ + super.key, + required this.fileName, + required this.error, + this.onRetry, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return ConduitErrorWidget( + title: 'File Error', + message: 'Failed to process $fileName: $error', + actionLabel: onRetry != null ? 'Try Again' : null, + onAction: onRetry, + icon: Icons.file_present, + isCompact: isCompact, + ); + } +} + +/// Enhanced authentication error widget with better hierarchy +class AuthErrorWidget extends StatelessWidget { + final String message; + final VoidCallback? onLogin; + final bool isCompact; + + const AuthErrorWidget({ + super.key, + required this.message, + this.onLogin, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return ConduitErrorWidget( + title: 'Authentication Required', + message: message, + actionLabel: onLogin != null ? 'Sign In' : null, + onAction: onLogin, + icon: Icons.lock_outline, + isCompact: isCompact, + ); + } +} + +/// Enhanced offline error widget with better hierarchy +class OfflineErrorWidget extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + final bool isCompact; + + const OfflineErrorWidget({ + super.key, + this.message = + 'You\'re currently offline. Please check your internet connection.', + this.onRetry, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return ConduitErrorWidget( + title: 'Offline', + message: message, + actionLabel: onRetry != null ? 'Retry' : null, + onAction: onRetry, + icon: Icons.wifi_off, + isCompact: isCompact, + ); + } +} + +/// Enhanced timeout error widget with better hierarchy +class TimeoutErrorWidget extends StatelessWidget { + final String operation; + final VoidCallback? onRetry; + final bool isCompact; + + const TimeoutErrorWidget({ + super.key, + required this.operation, + this.onRetry, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return ConduitErrorWidget( + title: 'Request Timeout', + message: 'The $operation request timed out. Please try again.', + actionLabel: onRetry != null ? 'Retry' : null, + onAction: onRetry, + icon: Icons.timer_off, + isCompact: isCompact, + ); + } +} diff --git a/lib/shared/widgets/improved_loading_states.dart b/lib/shared/widgets/improved_loading_states.dart new file mode 100644 index 0000000..824301e --- /dev/null +++ b/lib/shared/widgets/improved_loading_states.dart @@ -0,0 +1,666 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'skeleton_loader.dart'; +import '../theme/theme_extensions.dart'; +import 'conduit_components.dart'; + +/// Improved loading state widget with accessibility and better hierarchy +class ImprovedLoadingState extends StatefulWidget { + final String? message; + final bool showProgress; + final double? progress; + final Widget? customWidget; + final bool useSkeletonLoader; + final int skeletonCount; + final double skeletonHeight; + final bool isCompact; + + const ImprovedLoadingState({ + super.key, + this.message, + this.showProgress = false, + this.progress, + this.customWidget, + this.useSkeletonLoader = false, + this.skeletonCount = 3, + this.skeletonHeight = 100, + this.isCompact = false, + }); + + @override + State createState() => _ImprovedLoadingStateState(); +} + +class _ImprovedLoadingStateState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: AnimationDuration.standard, + vsync: this, + ); + _fadeAnimation = CurvedAnimation( + parent: _animationController, + curve: AnimationCurves.standard, + ); + _animationController.forward(); + + // Announce loading state for screen readers + if (widget.message != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + SemanticsService.announce( + 'Loading: ${widget.message}', + TextDirection.ltr, + ); + }); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.customWidget != null) { + return widget.customWidget!; + } + + if (widget.useSkeletonLoader) { + return _buildSkeletonLoader(); + } + + return FadeTransition( + opacity: _fadeAnimation, + child: Center( + child: Semantics( + label: widget.message ?? 'Loading content', + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.showProgress && widget.progress != null) + _buildProgressIndicator() + else + _buildCircularIndicator(), + + if (widget.message != null) ...[ + SizedBox(height: widget.isCompact ? Spacing.sm : Spacing.md), + Text( + widget.message!, + style: AppTypography.standard.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildCircularIndicator() { + return SizedBox( + width: widget.isCompact ? IconSize.large : IconSize.xxl, + height: widget.isCompact ? IconSize.large : IconSize.xxl, + child: CircularProgressIndicator( + strokeWidth: widget.isCompact ? 2 : 3, + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.buttonPrimary, + ), + ), + ); + } + + Widget _buildProgressIndicator() { + return Column( + children: [ + SizedBox( + width: widget.isCompact ? 150 : 200, + child: LinearProgressIndicator( + value: widget.progress, + minHeight: widget.isCompact ? 3 : 4, + backgroundColor: context.conduitTheme.dividerColor, + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.buttonPrimary, + ), + ), + ), + SizedBox(height: widget.isCompact ? Spacing.xs : Spacing.sm), + Text( + '${(widget.progress! * 100).toInt()}%', + style: AppTypography.small.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ); + } + + Widget _buildSkeletonLoader() { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.skeletonCount, + itemBuilder: (context, index) => Padding( + padding: EdgeInsets.symmetric( + horizontal: widget.isCompact ? Spacing.sm : Spacing.md, + vertical: widget.isCompact ? Spacing.xs : Spacing.sm, + ), + child: SkeletonLoader( + height: widget.skeletonHeight, + isCompact: widget.isCompact, + ), + ), + ); + } +} + +/// Improved empty state with better UX and hierarchy +class ImprovedEmptyState extends StatelessWidget { + final String title; + final String? subtitle; + final IconData? icon; + final Widget? customIcon; + final VoidCallback? onAction; + final String? actionLabel; + final bool showAnimation; + final bool isCompact; + + const ImprovedEmptyState({ + super.key, + required this.title, + this.subtitle, + this.icon, + this.customIcon, + this.onAction, + this.actionLabel, + this.showAnimation = true, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + + Widget content = Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Icon or custom widget + if (customIcon != null) + customIcon! + else if (icon != null) + showAnimation + ? TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: AnimationDuration.standard, + curve: AnimationCurves.elastic, + builder: (context, value, child) => Transform.scale( + scale: value, + child: Icon( + icon, + size: isCompact ? IconSize.large : IconSize.xxl, + color: theme.iconSecondary, + ), + ), + ) + : Icon( + icon, + size: isCompact ? IconSize.large : IconSize.xxl, + color: theme.iconSecondary, + ), + + SizedBox(height: isCompact ? Spacing.md : Spacing.lg), + + // Title + Text( + title, + style: AppTypography.headlineSmallStyle.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + + // Subtitle + if (subtitle != null) ...[ + SizedBox(height: isCompact ? Spacing.xs : Spacing.sm), + Text( + subtitle!, + style: AppTypography.standard.copyWith(color: theme.textSecondary), + textAlign: TextAlign.center, + ), + ], + + // Action button + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: isCompact ? Spacing.md : Spacing.lg), + ConduitButton( + text: actionLabel!, + onPressed: onAction, + isCompact: isCompact, + ), + ], + ], + ); + + return Center( + child: Padding( + padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.lg), + child: showAnimation + ? content.animate().fadeIn( + duration: AnimationDuration.standard, + curve: AnimationCurves.standard, + ) + : content, + ), + ); + } +} + +/// Enhanced loading overlay with better hierarchy +class LoadingOverlay extends StatelessWidget { + final Widget child; + final bool isLoading; + final String? message; + final bool isCompact; + + const LoadingOverlay({ + super.key, + required this.child, + required this.isLoading, + this.message, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + child, + if (isLoading) + Container( + color: context.conduitTheme.surfaceBackground.withValues( + alpha: Alpha.overlay, + ), + child: Center( + child: Container( + padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.lg), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + boxShadow: ConduitShadows.card, + ), + child: ImprovedLoadingState( + message: message, + isCompact: isCompact, + ), + ), + ), + ), + ], + ); + } +} + +/// Enhanced loading button with better hierarchy +class LoadingButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final bool isDestructive; + final bool isSecondary; + final IconData? icon; + final double? width; + final bool isFullWidth; + final bool isCompact; + + const LoadingButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.isDestructive = false, + this.isSecondary = false, + this.icon, + this.width, + this.isFullWidth = false, + this.isCompact = false, + }); + + @override + State createState() => _LoadingButtonState(); +} + +class _LoadingButtonState extends State { + @override + Widget build(BuildContext context) { + return ConduitButton( + text: widget.text, + onPressed: widget.isLoading ? null : widget.onPressed, + isLoading: widget.isLoading, + isDestructive: widget.isDestructive, + isSecondary: widget.isSecondary, + icon: widget.icon, + width: widget.width, + isFullWidth: widget.isFullWidth, + isCompact: widget.isCompact, + ); + } +} + +/// Enhanced loading list with better hierarchy +class LoadingList extends StatelessWidget { + final bool isLoading; + final Widget child; + final int skeletonCount; + final double skeletonHeight; + final bool isCompact; + + const LoadingList({ + super.key, + required this.isLoading, + required this.child, + this.skeletonCount = 5, + this.skeletonHeight = 80, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: skeletonCount, + itemBuilder: (context, index) => Padding( + padding: EdgeInsets.symmetric( + horizontal: isCompact ? Spacing.sm : Spacing.md, + vertical: isCompact ? Spacing.xs : Spacing.sm, + ), + child: SkeletonLoader(height: skeletonHeight, isCompact: isCompact), + ), + ); + } + + return child; + } +} + +/// Enhanced loading card with better hierarchy +class LoadingCard extends StatelessWidget { + final bool isLoading; + final Widget child; + final bool isCompact; + + const LoadingCard({ + super.key, + required this.isLoading, + required this.child, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return ConduitCard( + isCompact: isCompact, + child: ImprovedLoadingState( + message: 'Loading...', + isCompact: isCompact, + ), + ); + } + + return child; + } +} + +/// Shimmer loading effect +class ShimmerLoader extends StatefulWidget { + final double width; + final double height; + final BorderRadius? borderRadius; + final EdgeInsetsGeometry? margin; + + const ShimmerLoader({ + super.key, + this.width = double.infinity, + this.height = 20, + this.borderRadius, + this.margin, + }); + + @override + State createState() => _ShimmerLoaderState(); +} + +class _ShimmerLoaderState extends State + with SingleTickerProviderStateMixin { + late AnimationController _shimmerController; + late Animation _shimmerAnimation; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(); + + _shimmerAnimation = Tween(begin: -1.0, end: 2.0).animate( + CurvedAnimation(parent: _shimmerController, curve: Curves.linear), + ); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + + return Container( + width: widget.width, + height: widget.height, + margin: widget.margin, + decoration: BoxDecoration( + borderRadius: widget.borderRadius ?? BorderRadius.circular(4), + color: theme.surfaceContainer, + ), + child: AnimatedBuilder( + animation: _shimmerAnimation, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + borderRadius: widget.borderRadius ?? BorderRadius.circular(4), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + theme.shimmerBase, + theme.shimmerHighlight, + theme.shimmerBase, + ], + stops: [ + _shimmerAnimation.value - 0.3, + _shimmerAnimation.value, + _shimmerAnimation.value + 0.3, + ], + ), + ), + ); + }, + ), + ); + } +} + +/// Content placeholder for loading states +class ContentPlaceholder extends StatelessWidget { + final int lineCount; + final double lineHeight; + final double spacing; + final EdgeInsetsGeometry? padding; + final bool showAvatar; + final bool showActions; + + const ContentPlaceholder({ + super.key, + this.lineCount = 3, + this.lineHeight = 16, + this.spacing = 8, + this.padding, + this.showAvatar = false, + this.showActions = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding ?? const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAvatar) + Row( + children: [ + const ShimmerLoader( + width: 48, + height: 48, + borderRadius: BorderRadius.all(Radius.circular(24)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerLoader(width: 120, height: lineHeight), + SizedBox(height: spacing / 2), + ShimmerLoader(width: 80, height: lineHeight * 0.8), + ], + ), + ), + ], + ), + + if (showAvatar) SizedBox(height: spacing * 2), + + ...List.generate(lineCount, (index) { + final isLast = index == lineCount - 1; + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : spacing), + child: ShimmerLoader( + width: isLast ? 200 : double.infinity, + height: lineHeight, + ), + ); + }), + + if (showActions) ...[ + SizedBox(height: spacing * 2), + Row( + children: [ + ShimmerLoader( + width: 80, + height: 32, + borderRadius: BorderRadius.circular(16), + ), + const SizedBox(width: 8), + ShimmerLoader( + width: 80, + height: 32, + borderRadius: BorderRadius.circular(16), + ), + ], + ), + ], + ], + ), + ); + } +} + +/// Error state widget with retry +class ErrorStateWidget extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + final Object? error; + final bool showDetails; + + const ErrorStateWidget({ + super.key, + required this.message, + this.onRetry, + this.error, + this.showDetails = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text( + 'Oops! Something went wrong', + style: theme.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + + if (showDetails && error != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + error.toString(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + + if (onRetry != null) ...[ + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/loading_states.dart b/lib/shared/widgets/loading_states.dart new file mode 100644 index 0000000..5cafad0 --- /dev/null +++ b/lib/shared/widgets/loading_states.dart @@ -0,0 +1,429 @@ +import 'package:flutter/material.dart'; +import '../theme/theme_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:io' show Platform; +import '../services/brand_service.dart'; +import '../theme/app_theme.dart'; + +/// Standard loading indicators following Conduit design patterns +class ConduitLoading { + // Private constructor to prevent instantiation + ConduitLoading._(); + + /// Primary loading indicator + static Widget primary({ + double size = IconSize.lg, + Color? color, + String? message, + }) { + return _LoadingIndicator( + size: size, + color: color ?? BrandService.primaryBrandColor, + message: message, + type: _LoadingType.primary, + ); + } + + /// Inline loading for content areas + static Widget inline({ + double size = IconSize.md, + Color? color, + String? message, + BuildContext? context, + }) { + return _LoadingIndicator( + size: size, + color: + color ?? + (context?.conduitTheme.loadingIndicator ?? + context?.conduitTheme.buttonPrimary ?? + AppTheme.brandPrimary), + message: message, + type: _LoadingType.inline, + ); + } + + /// Button loading state + static Widget button({ + double size = IconSize.sm, + Color? color, + BuildContext? context, + }) { + return _LoadingIndicator( + size: size, + color: + color ?? + (context?.conduitTheme.buttonPrimaryText ?? + context?.conduitTheme.textPrimary ?? + AppTheme.neutral50), + type: _LoadingType.button, + ); + } + + /// Overlay loading for full screen + static Widget overlay({String? message, bool darkBackground = true}) { + return _LoadingOverlay(message: message, darkBackground: darkBackground); + } + + /// Skeleton loading for content placeholders + static Widget skeleton({ + double width = double.infinity, + double height = 20, + BorderRadius? borderRadius, + }) { + return _SkeletonLoader( + width: width, + height: height, + borderRadius: borderRadius ?? BorderRadius.circular(AppBorderRadius.xs), + ); + } + + /// List item skeleton + static Widget listItemSkeleton({bool showAvatar = true, int lines = 2}) { + return _ListItemSkeleton(showAvatar: showAvatar, lines: lines); + } +} + +enum _LoadingType { primary, inline, button } + +class _LoadingIndicator extends StatelessWidget { + final double size; + final Color color; + final String? message; + final _LoadingType type; + + const _LoadingIndicator({ + required this.size, + required this.color, + this.message, + required this.type, + }); + + @override + Widget build(BuildContext context) { + Widget indicator; + + if (Platform.isIOS) { + indicator = CupertinoActivityIndicator(color: color, radius: size / 2); + } else { + indicator = SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: size / 8, + valueColor: AlwaysStoppedAnimation(color), + ), + ); + } + + if (message == null) { + return indicator; + } + + final spacing = type == _LoadingType.button ? Spacing.sm : Spacing.xs; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + indicator, + SizedBox(height: spacing), + Text( + message!, + style: TextStyle( + color: color, + fontSize: type == _LoadingType.button + ? AppTypography.bodySmall + : AppTypography.bodyLarge, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ], + ); + } +} + +class _LoadingOverlay extends StatelessWidget { + final String? message; + final bool darkBackground; + + const _LoadingOverlay({this.message, required this.darkBackground}); + + @override + Widget build(BuildContext context) { + return Container( + color: darkBackground + ? context.conduitTheme.surfaceBackground.withValues( + alpha: Alpha.strong, + ) + : context.conduitTheme.surfaceBackground.withValues( + alpha: Alpha.intense, + ), + child: Center( + child: Container( + padding: const EdgeInsets.all(Spacing.lg), + decoration: BoxDecoration( + color: darkBackground + ? context.conduitTheme.surfaceBackground + : context.conduitTheme.surfaceBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + boxShadow: ConduitShadows.high, + ), + child: ConduitLoading.primary( + size: IconSize.xl, + color: context.conduitTheme.buttonPrimary, + message: message, + ), + ), + ), + ).animate().fadeIn(duration: AnimationDuration.fast); + } +} + +class _SkeletonLoader extends StatefulWidget { + final double width; + final double height; + final BorderRadius borderRadius; + + const _SkeletonLoader({ + required this.width, + required this.height, + required this.borderRadius, + }); + + @override + State<_SkeletonLoader> createState() => _SkeletonLoaderState(); +} + +class _SkeletonLoaderState extends State<_SkeletonLoader> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: AnimationDuration.ultra, + vsync: this, + ); + _animation = + Tween( + begin: AnimationValues.shimmerBegin, + end: AnimationValues.shimmerEnd, + ).animate( + CurvedAnimation( + parent: _controller, + curve: AnimationCurves.easeInOut, + ), + ); + _controller.repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + borderRadius: widget.borderRadius, + color: context.conduitTheme.shimmerBase, + ), + child: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + borderRadius: widget.borderRadius, + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.transparent, + context.conduitTheme.shimmerHighlight, + Colors.transparent, + ], + stops: [ + (_animation.value - 0.3).clamp(0.0, 1.0), + _animation.value.clamp(0.0, 1.0), + (_animation.value + 0.3).clamp(0.0, 1.0), + ], + ), + ), + ); + }, + ), + ); + } +} + +class _ListItemSkeleton extends StatelessWidget { + final bool showAvatar; + final int lines; + + const _ListItemSkeleton({required this.showAvatar, required this.lines}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: Row( + children: [ + if (showAvatar) ...[ + ConduitLoading.skeleton( + width: TouchTarget.minimum, + height: TouchTarget.minimum, + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + ), + const SizedBox(width: Spacing.xs), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(lines, (index) { + return Padding( + padding: EdgeInsets.only( + bottom: index < lines - 1 ? Spacing.sm : 0, + ), + child: ConduitLoading.skeleton( + width: index == lines - 1 ? 150 : double.infinity, + height: index == 0 ? 16 : 14, + ), + ); + }), + ), + ), + ], + ), + ); + } +} + +/// Loading state wrapper for async operations +class LoadingStateWrapper extends StatelessWidget { + final AsyncValue asyncValue; + final Widget Function(T data) builder; + final Widget? loadingWidget; + final Widget Function(Object error, StackTrace stackTrace)? errorBuilder; + final bool showLoadingOverlay; + + const LoadingStateWrapper({ + super.key, + required this.asyncValue, + required this.builder, + this.loadingWidget, + this.errorBuilder, + this.showLoadingOverlay = false, + }); + + @override + Widget build(BuildContext context) { + return asyncValue.when( + data: builder, + loading: () => showLoadingOverlay + ? ConduitLoading.overlay(message: 'Loading...') + : loadingWidget ?? ConduitLoading.primary(message: 'Loading...'), + error: (error, stackTrace) { + if (errorBuilder != null) { + return errorBuilder!(error, stackTrace); + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_triangle + : Icons.error_outline, + size: IconSize.xxl, + color: context.conduitTheme.error, + ), + const SizedBox(height: Spacing.md), + Text( + 'Something went wrong', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: Spacing.sm), + Text( + error.toString(), + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + ); + } +} + +/// Button with loading state +class LoadingButton extends StatelessWidget { + final VoidCallback? onPressed; + final Widget child; + final bool isLoading; + final bool isPrimary; + + const LoadingButton({ + super.key, + required this.onPressed, + required this.child, + this.isLoading = false, + this.isPrimary = true, + }); + + @override + Widget build(BuildContext context) { + return FilledButton( + onPressed: isLoading ? null : onPressed, + style: isPrimary + ? FilledButton.styleFrom( + backgroundColor: context.conduitTheme.buttonPrimary, + foregroundColor: context.conduitTheme.buttonPrimaryText, + ) + : null, + child: isLoading ? ConduitLoading.button(context: context) : child, + ); + } +} + +/// Refresh indicator with Conduit styling +class ConduitRefreshIndicator extends StatelessWidget { + final Widget child; + final Future Function() onRefresh; + + const ConduitRefreshIndicator({ + super.key, + required this.child, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: onRefresh, + color: context.conduitTheme.buttonPrimary, + backgroundColor: context.conduitTheme.surfaceBackground, + child: child, + ); + } +} diff --git a/lib/shared/widgets/offline_indicator.dart b/lib/shared/widgets/offline_indicator.dart new file mode 100644 index 0000000..d8e3d0b --- /dev/null +++ b/lib/shared/widgets/offline_indicator.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:io' show Platform; +import '../../core/services/connectivity_service.dart'; +import '../theme/theme_extensions.dart'; + +class OfflineIndicator extends ConsumerWidget { + final Widget child; + final bool showBanner; + + const OfflineIndicator({ + super.key, + required this.child, + this.showBanner = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final connectivityStatus = ref.watch(connectivityStatusProvider); + + return Stack( + children: [ + child, + if (showBanner) + connectivityStatus.when( + data: (status) { + if (status == ConnectivityStatus.offline) { + return _OfflineBanner(); + } + return const SizedBox.shrink(); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => _OfflineBanner(), + ), + ], + ); + } +} + +class _OfflineBanner extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.warning, + boxShadow: ConduitShadows.low, + ), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.wifi_slash + : Icons.wifi_off, + color: context.conduitTheme.textInverse, + size: AppTypography.headlineMedium, + ), + const SizedBox(width: Spacing.xs), + Expanded( + child: Text( + 'You\'re offline. Some features may be limited.', + style: TextStyle( + color: context.conduitTheme.textInverse, + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ) + .animate(onPlay: (controller) => controller.forward()) + .slideY( + begin: -1, + end: 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ), + ), + ); + } +} + +// Inline offline indicator for specific features +class InlineOfflineIndicator extends ConsumerWidget { + final String message; + final IconData? icon; + final Color? backgroundColor; + + const InlineOfflineIndicator({ + super.key, + this.message = 'This feature requires an internet connection', + this.icon, + this.backgroundColor, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOnline = ref.watch(isOnlineProvider); + + if (isOnline) { + return const SizedBox.shrink(); + } + + return Container( + margin: const EdgeInsets.all(Spacing.md), + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: + backgroundColor ?? + context.conduitTheme.warning.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.warning.withValues(alpha: 0.3), + width: BorderWidth.regular, + ), + ), + child: Row( + children: [ + Icon( + icon ?? + (Platform.isIOS ? CupertinoIcons.wifi_slash : Icons.wifi_off), + color: context.conduitTheme.warning, + size: Spacing.lg, + ), + const SizedBox(width: Spacing.xs), + Expanded( + child: Text( + message, + style: TextStyle( + color: context.conduitTheme.warning, + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ).animate().fadeIn(duration: const Duration(milliseconds: 300)); + } +} + +// Offline-aware button that disables when offline +class OfflineAwareButton extends ConsumerWidget { + final VoidCallback? onPressed; + final Widget child; + final bool requiresConnection; + final String? offlineTooltip; + + const OfflineAwareButton({ + super.key, + required this.onPressed, + required this.child, + this.requiresConnection = true, + this.offlineTooltip, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOnline = ref.watch(isOnlineProvider); + final enabled = !requiresConnection || isOnline; + + return Tooltip( + message: !enabled + ? (offlineTooltip ?? 'This action requires an internet connection') + : '', + child: FilledButton(onPressed: enabled ? onPressed : null, child: child), + ); + } +} + +// Chat-specific offline indicator +class ChatOfflineOverlay extends ConsumerWidget { + const ChatOfflineOverlay({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOnline = ref.watch(isOnlineProvider); + + if (isOnline) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + color: context.conduitTheme.warning.withValues(alpha: 0.2), + border: Border( + top: BorderSide( + color: context.conduitTheme.warning.withValues(alpha: 0.5), + width: BorderWidth.regular, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.wifi_slash : Icons.wifi_off, + color: context.conduitTheme.warning, + size: Spacing.md, + ), + const SizedBox(width: Spacing.sm), + Text( + 'Messages will be sent when you\'re back online', + style: TextStyle( + color: context.conduitTheme.warning, + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ).animate().fadeIn(duration: const Duration(milliseconds: 300)); + } +} diff --git a/lib/shared/widgets/optimized_list.dart b/lib/shared/widgets/optimized_list.dart new file mode 100644 index 0000000..4afe19d --- /dev/null +++ b/lib/shared/widgets/optimized_list.dart @@ -0,0 +1,414 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'skeleton_loader.dart'; +import 'improved_loading_states.dart'; + +/// Optimized list widget with virtualization and performance enhancements +class OptimizedList extends ConsumerStatefulWidget { + final List items; + final Widget Function(BuildContext context, T item, int index) itemBuilder; + final Widget? separatorBuilder; + final Widget? loadingWidget; + final Widget? emptyWidget; + final String? emptyMessage; + final Future Function()? onRefresh; + final VoidCallback? onLoadMore; + final bool hasMore; + final bool isLoading; + final EdgeInsetsGeometry? padding; + final ScrollController? scrollController; + final ScrollPhysics? physics; + final bool shrinkWrap; + final Axis scrollDirection; + final bool reverse; + final double? cacheExtent; + final int? itemExtent; + final bool addAutomaticKeepAlives; + final bool addRepaintBoundaries; + final bool enablePagination; + final double paginationThreshold; + + const OptimizedList({ + super.key, + required this.items, + required this.itemBuilder, + this.separatorBuilder, + this.loadingWidget, + this.emptyWidget, + this.emptyMessage, + this.onRefresh, + this.onLoadMore, + this.hasMore = false, + this.isLoading = false, + this.padding, + this.scrollController, + this.physics, + this.shrinkWrap = false, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.cacheExtent, + this.itemExtent, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.enablePagination = false, + this.paginationThreshold = 0.8, + }); + + @override + ConsumerState> createState() => _OptimizedListState(); +} + +class _OptimizedListState extends ConsumerState> { + late ScrollController _scrollController; + bool _isLoadingMore = false; + final Set _visibleIndices = {}; + + @override + void initState() { + super.initState(); + _scrollController = widget.scrollController ?? ScrollController(); + + if (widget.enablePagination) { + _scrollController.addListener(_onScroll); + } + } + + @override + void dispose() { + if (widget.scrollController == null) { + _scrollController.dispose(); + } + super.dispose(); + } + + void _onScroll() { + if (!widget.enablePagination || + _isLoadingMore || + !widget.hasMore || + widget.onLoadMore == null) { + return; + } + + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + final threshold = maxScroll * widget.paginationThreshold; + + if (currentScroll >= threshold) { + _loadMore(); + } + } + + Future _loadMore() async { + if (_isLoadingMore) return; + + setState(() { + _isLoadingMore = true; + }); + + try { + widget.onLoadMore?.call(); + } finally { + if (mounted) { + setState(() { + _isLoadingMore = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + // Show loading state + if (widget.isLoading && widget.items.isEmpty) { + return widget.loadingWidget ?? _buildDefaultLoadingWidget(); + } + + // Show empty state + if (widget.items.isEmpty) { + return widget.emptyWidget ?? + ImprovedEmptyState( + title: 'No items', + subtitle: widget.emptyMessage ?? 'No items to display', + icon: Icons.inbox_outlined, + ); + } + + // Build the list + Widget listWidget; + + if (widget.separatorBuilder != null) { + listWidget = ListView.separated( + controller: _scrollController, + padding: widget.padding, + physics: widget.physics ?? const AlwaysScrollableScrollPhysics(), + shrinkWrap: widget.shrinkWrap, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: widget.cacheExtent ?? 250.0, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + itemCount: widget.items.length + (widget.hasMore ? 1 : 0), + separatorBuilder: (context, index) => widget.separatorBuilder!, + itemBuilder: (context, index) { + if (index >= widget.items.length) { + return _buildLoadMoreIndicator(); + } + + return _buildOptimizedItem(context, index); + }, + ); + } else { + listWidget = ListView.builder( + controller: _scrollController, + padding: widget.padding, + physics: widget.physics ?? const AlwaysScrollableScrollPhysics(), + shrinkWrap: widget.shrinkWrap, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: widget.cacheExtent ?? 250.0, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + itemCount: widget.items.length + (widget.hasMore ? 1 : 0), + itemExtent: widget.itemExtent?.toDouble(), + itemBuilder: (context, index) { + if (index >= widget.items.length) { + return _buildLoadMoreIndicator(); + } + + return _buildOptimizedItem(context, index); + }, + ); + } + + // Add refresh indicator if enabled + if (widget.onRefresh != null) { + return RefreshIndicator(onRefresh: widget.onRefresh!, child: listWidget); + } + + return listWidget; + } + + Widget _buildOptimizedItem(BuildContext context, int index) { + final item = widget.items[index]; + + // Track visible items for analytics + _visibleIndices.add(index); + + // Wrap in repaint boundary for performance + if (widget.addRepaintBoundaries) { + return RepaintBoundary(child: widget.itemBuilder(context, item, index)); + } + + return widget.itemBuilder(context, item, index); + } + + Widget _buildLoadMoreIndicator() { + return Container( + padding: const EdgeInsets.all(16.0), + alignment: Alignment.center, + child: _isLoadingMore + ? const CircularProgressIndicator() + : TextButton(onPressed: _loadMore, child: const Text('Load More')), + ); + } + + Widget _buildDefaultLoadingWidget() { + return ListView.builder( + padding: widget.padding, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: 5, + itemBuilder: (context, index) => const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SkeletonLoader(height: 80), + ), + ); + } +} + +/// Sliver version of OptimizedList for use in CustomScrollView +class OptimizedSliverList extends ConsumerWidget { + final List items; + final Widget Function(BuildContext context, T item, int index) itemBuilder; + final Widget? loadingWidget; + final Widget? emptyWidget; + final String? emptyMessage; + final bool isLoading; + final bool hasMore; + final VoidCallback? onLoadMore; + final bool addAutomaticKeepAlives; + final bool addRepaintBoundaries; + + const OptimizedSliverList({ + super.key, + required this.items, + required this.itemBuilder, + this.loadingWidget, + this.emptyWidget, + this.emptyMessage, + this.isLoading = false, + this.hasMore = false, + this.onLoadMore, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Show loading state + if (isLoading && items.isEmpty) { + return SliverToBoxAdapter( + child: loadingWidget ?? _buildDefaultLoadingWidget(), + ); + } + + // Show empty state + if (items.isEmpty) { + return SliverToBoxAdapter( + child: + emptyWidget ?? + ImprovedEmptyState( + title: 'No items', + subtitle: emptyMessage ?? 'No items to display', + icon: Icons.inbox_outlined, + ), + ); + } + + // Build the list + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= items.length) { + if (hasMore) { + // Trigger load more + WidgetsBinding.instance.addPostFrameCallback((_) { + onLoadMore?.call(); + }); + + return Container( + padding: const EdgeInsets.all(16.0), + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ); + } + return null; + } + + final item = items[index]; + final widget = itemBuilder(context, item, index); + + // Wrap in repaint boundary for performance + if (addRepaintBoundaries) { + return RepaintBoundary(child: widget); + } + + return widget; + }, + childCount: items.length + (hasMore ? 1 : 0), + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + ), + ); + } + + Widget _buildDefaultLoadingWidget() { + return Column( + children: List.generate( + 5, + (index) => const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SkeletonLoader(height: 80), + ), + ), + ); + } +} + +/// Animated list with optimizations +class OptimizedAnimatedList extends ConsumerStatefulWidget { + final List items; + final Widget Function( + BuildContext context, + T item, + int index, + Animation animation, + ) + itemBuilder; + final Duration animationDuration; + final Curve animationCurve; + final EdgeInsetsGeometry? padding; + final ScrollController? scrollController; + final bool shrinkWrap; + + const OptimizedAnimatedList({ + super.key, + required this.items, + required this.itemBuilder, + this.animationDuration = const Duration(milliseconds: 300), + this.animationCurve = Curves.easeInOut, + this.padding, + this.scrollController, + this.shrinkWrap = false, + }); + + @override + ConsumerState> createState() => + _OptimizedAnimatedListState(); +} + +class _OptimizedAnimatedListState + extends ConsumerState> { + final GlobalKey _listKey = GlobalKey(); + late List _items; + + @override + void initState() { + super.initState(); + _items = List.from(widget.items); + } + + @override + void didUpdateWidget(OptimizedAnimatedList oldWidget) { + super.didUpdateWidget(oldWidget); + + // Handle item additions + for (int i = 0; i < widget.items.length; i++) { + if (i >= _items.length || widget.items[i] != _items[i]) { + _items.insert(i, widget.items[i]); + _listKey.currentState?.insertItem( + i, + duration: widget.animationDuration, + ); + } + } + + // Handle item removals + for (int i = _items.length - 1; i >= widget.items.length; i--) { + final removedItem = _items[i]; + _items.removeAt(i); + _listKey.currentState?.removeItem( + i, + (context, animation) => + widget.itemBuilder(context, removedItem, i, animation), + duration: widget.animationDuration, + ); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedList( + key: _listKey, + controller: widget.scrollController, + padding: widget.padding, + shrinkWrap: widget.shrinkWrap, + initialItemCount: _items.length, + itemBuilder: (context, index, animation) { + if (index >= _items.length) return const SizedBox.shrink(); + + return widget.itemBuilder(context, _items[index], index, animation); + }, + ); + } +} diff --git a/lib/shared/widgets/skeleton_loader.dart b/lib/shared/widgets/skeleton_loader.dart new file mode 100644 index 0000000..bf88020 --- /dev/null +++ b/lib/shared/widgets/skeleton_loader.dart @@ -0,0 +1,361 @@ +import 'package:flutter/material.dart'; +import '../theme/theme_extensions.dart'; + +/// Enhanced skeleton loader with production-grade animations and better hierarchy +class SkeletonLoader extends StatefulWidget { + final double? width; + final double? height; + final BorderRadius? borderRadius; + final Duration? duration; + final Color? baseColor; + final Color? highlightColor; + final bool isCompact; + + const SkeletonLoader({ + super.key, + this.width, + this.height, + this.borderRadius, + this.duration, + this.baseColor, + this.highlightColor, + this.isCompact = false, + }); + + @override + State createState() => _SkeletonLoaderState(); +} + +class _SkeletonLoaderState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration ?? AnimationDuration.typingIndicator, + vsync: this, + ); + _animation = + Tween( + begin: AnimationValues.shimmerBegin, + end: AnimationValues.shimmerEnd, + ).animate( + CurvedAnimation(parent: _controller, curve: AnimationCurves.linear), + ); + + _controller.repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + borderRadius: + widget.borderRadius ?? + BorderRadius.circular( + widget.isCompact ? AppBorderRadius.xs : AppBorderRadius.sm, + ), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + widget.baseColor ?? context.conduitTheme.shimmerBase, + widget.highlightColor ?? context.conduitTheme.shimmerHighlight, + widget.baseColor ?? context.conduitTheme.shimmerBase, + ], + stops: [ + _animation.value - 0.3, + _animation.value, + _animation.value + 0.3, + ], + ), + ), + ); + }, + ); + } +} + +/// Enhanced skeleton for chat messages with better hierarchy +class SkeletonChatMessage extends StatelessWidget { + final bool isUser; + final int lines; + final bool isCompact; + + const SkeletonChatMessage({ + super.key, + this.isUser = false, + this.lines = 2, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: isCompact ? Spacing.sm : Spacing.messagePadding, + vertical: isCompact ? Spacing.xs : Spacing.sm, + ), + child: Row( + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser) ...[ + SkeletonLoader( + width: isCompact ? 32 : 40, + height: isCompact ? 32 : 40, + borderRadius: BorderRadius.circular(AppBorderRadius.avatar), + ), + SizedBox(width: isCompact ? Spacing.xs : Spacing.sm), + ], + Expanded( + child: Column( + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + for (int i = 0; i < lines; i++) + Padding( + padding: EdgeInsets.only( + bottom: i < lines - 1 + ? (isCompact ? Spacing.xs : Spacing.sm) + : 0, + ), + child: SkeletonLoader( + width: isUser + ? null + : (MediaQuery.of(context).size.width * 0.6), + height: isCompact ? 12 : 16, + borderRadius: BorderRadius.circular( + isCompact ? AppBorderRadius.xs : AppBorderRadius.sm, + ), + ), + ), + ], + ), + ), + if (isUser) ...[ + SizedBox(width: isCompact ? Spacing.xs : Spacing.sm), + SkeletonLoader( + width: isCompact ? 32 : 40, + height: isCompact ? 32 : 40, + borderRadius: BorderRadius.circular(AppBorderRadius.avatar), + ), + ], + ], + ), + ); + } +} + +/// Enhanced skeleton for list items with better hierarchy +class SkeletonListItem extends StatelessWidget { + final bool showAvatar; + final bool showSubtitle; + final bool isCompact; + + const SkeletonListItem({ + super.key, + this.showAvatar = true, + this.showSubtitle = true, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(isCompact ? Spacing.sm : Spacing.listItemPadding), + child: Row( + children: [ + if (showAvatar) ...[ + SkeletonLoader( + width: isCompact ? 32 : 40, + height: isCompact ? 32 : 40, + borderRadius: BorderRadius.circular(AppBorderRadius.avatar), + ), + SizedBox(width: isCompact ? Spacing.sm : Spacing.md), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonLoader( + width: double.infinity, + height: isCompact ? 14 : 16, + borderRadius: BorderRadius.circular( + isCompact ? AppBorderRadius.xs : AppBorderRadius.sm, + ), + ), + if (showSubtitle) ...[ + SizedBox(height: isCompact ? Spacing.xs : Spacing.sm), + SkeletonLoader( + width: MediaQuery.of(context).size.width * 0.7, + height: isCompact ? 12 : 14, + borderRadius: BorderRadius.circular( + isCompact ? AppBorderRadius.xs : AppBorderRadius.sm, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +/// Enhanced skeleton for cards with better hierarchy +class SkeletonCard extends StatelessWidget { + final bool showTitle; + final bool showContent; + final bool showActions; + final bool isCompact; + + const SkeletonCard({ + super.key, + this.showTitle = true, + this.showContent = true, + this.showActions = false, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(isCompact ? Spacing.sm : Spacing.cardPadding), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.standard, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showTitle) ...[ + SkeletonLoader( + width: MediaQuery.of(context).size.width * 0.8, + height: isCompact ? 16 : 20, + borderRadius: BorderRadius.circular( + isCompact ? AppBorderRadius.xs : AppBorderRadius.sm, + ), + ), + SizedBox(height: isCompact ? Spacing.sm : Spacing.md), + ], + if (showContent) ...[ + SkeletonLoader( + width: double.infinity, + height: isCompact ? 12 : 14, + borderRadius: BorderRadius.circular( + isCompact ? AppBorderRadius.xs : AppBorderRadius.sm, + ), + ), + SizedBox(height: isCompact ? Spacing.xs : Spacing.sm), + SkeletonLoader( + width: MediaQuery.of(context).size.width * 0.6, + height: isCompact ? 12 : 14, + borderRadius: BorderRadius.circular( + isCompact ? AppBorderRadius.xs : AppBorderRadius.sm, + ), + ), + if (showActions) ...[ + SizedBox(height: isCompact ? Spacing.md : Spacing.lg), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SkeletonLoader( + width: isCompact ? 60 : 80, + height: isCompact ? 32 : 40, + borderRadius: BorderRadius.circular(AppBorderRadius.button), + ), + SizedBox(width: isCompact ? Spacing.sm : Spacing.md), + SkeletonLoader( + width: isCompact ? 60 : 80, + height: isCompact ? 32 : 40, + borderRadius: BorderRadius.circular(AppBorderRadius.button), + ), + ], + ), + ], + ], + ], + ), + ); + } +} + +/// Enhanced skeleton for input fields with better hierarchy +class SkeletonInput extends StatelessWidget { + final bool showLabel; + final bool isCompact; + + const SkeletonInput({ + super.key, + this.showLabel = true, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showLabel) ...[ + SkeletonLoader( + width: 80, + height: isCompact ? 14 : 16, + borderRadius: BorderRadius.circular( + isCompact ? AppBorderRadius.xs : AppBorderRadius.sm, + ), + ), + SizedBox(height: isCompact ? Spacing.xs : Spacing.sm), + ], + SkeletonLoader( + width: double.infinity, + height: isCompact ? 40 : 48, + borderRadius: BorderRadius.circular(AppBorderRadius.input), + ), + ], + ); + } +} + +/// Enhanced skeleton for buttons with better hierarchy +class SkeletonButton extends StatelessWidget { + final bool isFullWidth; + final bool isCompact; + + const SkeletonButton({ + super.key, + this.isFullWidth = false, + this.isCompact = false, + }); + + @override + Widget build(BuildContext context) { + return SkeletonLoader( + width: isFullWidth ? double.infinity : (isCompact ? 80 : 120), + height: isCompact ? TouchTarget.medium : TouchTarget.comfortable, + borderRadius: BorderRadius.circular(AppBorderRadius.button), + ); + } +} diff --git a/lib/shared/widgets/themed_dialogs.dart b/lib/shared/widgets/themed_dialogs.dart new file mode 100644 index 0000000..e948175 --- /dev/null +++ b/lib/shared/widgets/themed_dialogs.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import '../theme/theme_extensions.dart'; + +/// Centralized helper for building themed dialogs consistently +class ThemedDialogs { + ThemedDialogs._(); + + /// Build a base themed AlertDialog + static AlertDialog buildBase({ + required BuildContext context, + required String title, + Widget? content, + List? actions, + }) { + return AlertDialog( + backgroundColor: context.conduitTheme.surfaceBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.dialog), + ), + title: Text( + title, + style: TextStyle(color: context.conduitTheme.textPrimary), + ), + content: content, + actions: actions, + ); + } + + /// Show a simple confirmation dialog with Cancel/Confirm actions + static Future confirm( + BuildContext context, { + required String title, + required String message, + String confirmText = 'Confirm', + String cancelText = 'Cancel', + bool isDestructive = false, + bool barrierDismissible = true, + }) async { + final result = await showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (ctx) => buildBase( + context: ctx, + title: title, + content: Text( + message, + style: TextStyle(color: ctx.conduitTheme.textSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text( + cancelText, + style: TextStyle(color: ctx.conduitTheme.textSecondary), + ), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom( + foregroundColor: isDestructive + ? ctx.conduitTheme.error + : ctx.conduitTheme.buttonPrimary, + ), + child: Text( + confirmText, + style: TextStyle( + color: isDestructive + ? ctx.conduitTheme.error + : ctx.conduitTheme.buttonPrimary, + ), + ), + ), + ], + ), + ); + + return result ?? false; + } + + /// Show a generic themed dialog + static Future show( + BuildContext context, { + required String title, + required Widget content, + List? actions, + bool barrierDismissible = true, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (ctx) => buildBase( + context: ctx, + title: title, + content: content, + actions: actions, + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..3088429 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1418 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "0b1b12a0a549605e5f04476031cd0bc91ead1d7c8e830773a18ee54179b3cb62" + url: "https://pub.dev" + source: hosted + version: "8.11.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "13ba4e627ef24503a465d1d61b32596ce10eb6b8903678d362a528f9939b4aa8" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" + url: "https://pub.dev" + source: hosted + version: "0.9.4+3" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_math_fork: + dependency: transitive + description: + name: flutter_math_fork + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + url: "https://pub.dev" + source: hosted + version: "0.7.4" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" + url: "https://pub.dev" + source: hosted + version: "2.4.6" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "2d399f823b8849663744d2a9ddcce01c49268fb4170d0442a655bf6a2f47be22" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + gpt_markdown: + dependency: "direct main" + description: + name: gpt_markdown + sha256: "68d5337c8a00fc03a37dbddf84a6fd90401c30e99b6baf497ef9522a81fc34ee" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: "direct main" + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6fae381e6af2bbe0365a5e4ce1db3959462fa0c4d234facf070746024bb80c8d" + url: "https://pub.dev" + source: hosted + version: "0.8.12+24" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" + url: "https://pub.dev" + source: hosted + version: "0.8.12+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: transitive + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record: + dependency: "direct main" + description: + name: record + sha256: daeb3f9b3fea9797094433fe6e49a879d8e4ca4207740bc6dc7e4a58764f0817 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "97d7122455f30de89a01c6c244c839085be6b12abca251fc0e78f67fed73628b" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "73706ebbece6150654c9d6f57897cf9b622c581148304132ba85dba15df0fdfb" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "0626678a092c75ce6af1e32fe7fd1dea709b92d308bc8e3b6d6348e2430beb95" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "02240833fde16c33fcf2c589f3e08d4394b704761b4a3bb609d872ff3043fbbd" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: c1ad38f51e4af88a085b3e792a22c685cb3e7c23fc37aa7ce44c4cf18f25fe89 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: a12856d0b3dd03d336b4b10d7520a8b3e21649a06a8f95815318feaa8f07adbb + url: "https://pub.dev" + source: hosted + version: "1.1.9" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "85a22fc97f6d73ecd67c8ba5f2f472b74ef1d906f795b7970f771a0914167e99" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: c8471c2c6843cf308a5532ff653f2bcdb7fa9ae79d84d1179920578a06624f0d + url: "https://pub.dev" + source: hosted + version: "3.1.2" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4f81479fe5194a622cdd1713fe1ecb683a6e6c85cd8cec8e2e35ee5ab3fdf2a1" + url: "https://pub.dev" + source: hosted + version: "1.3.6" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sse: + dependency: "direct main" + description: + name: sse + sha256: fcc97470240bb37377f298e2bd816f09fd7216c07928641c0560719f50603643 + url: "https://pub.dev" + source: hosted + version: "4.1.8" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" + url: "https://pub.dev" + source: hosted + version: "6.3.17" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" + url: "https://pub.dev" + source: hosted + version: "1.1.17" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..bd20423 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,88 @@ +name: conduit +description: Open-source mobile client for Open-WebUI +version: 1.0.0+1 +publish_to: 'none' + +environment: + sdk: ">=3.8.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + + # State Management + flutter_riverpod: ^2.6.1 + + # Network & API + dio: ^5.5.0 + http_parser: ^4.0.2 + web_socket_channel: ^3.0.1 + socket_io_client: ^3.0.4 + sse: ^4.1.2 + + # Storage + flutter_secure_storage: ^9.2.2 + shared_preferences: ^2.3.2 + + # UI Components - Enhanced Markdown + gpt_markdown: ^1.1.2 + cached_network_image: ^3.3.1 + + + + # Modern Animations + flutter_animate: ^4.5.0 + + # Platform Features + record: ^6.0.0 + image_picker: ^1.1.2 + file_picker: ^10.2.1 + + # Utilities + path: ^1.9.0 + uuid: ^4.5.0 + collection: ^1.18.0 + crypto: ^3.0.3 + package_info_plus: ^8.0.2 + url_launcher: ^6.3.0 + + # Icons & Theming + cupertino_icons: ^1.0.8 + freezed_annotation: ^3.0.0 + json_annotation: ^4.9.0 + google_fonts: ^6.2.1 + + # Clipboard functionality is available through flutter/services (part of Flutter SDK) + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + build_runner: ^2.4.11 + freezed: ^3.0.0 + json_serializable: ^6.8.0 + flutter_native_splash: ^2.4.6 + +dependency_overrides: + +flutter: + uses-material-design: true + + assets: + - assets/icons/ + - assets/openapi.json + +flutter_native_splash: + # Background color (Conduit dark theme) + color: "#000000" + # Image to display on the splash screen + image: assets/icons/icon.png + + + # Android specific settings + android_12: + color: "#000000" + + # Web specific settings + web: false + diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..46533fb --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# LuCI Mobile Release Script (CI-driven) +# Usage: ./scripts/release.sh [major|minor|patch] + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if we're in the right directory +if [ ! -f "pubspec.yaml" ]; then + print_error "This script must be run from the project root directory" + exit 1 +fi + +# Check if git is clean +if [ -n "$(git status --porcelain)" ]; then + print_error "Working directory is not clean. Please commit or stash your changes first." + exit 1 +fi + +# Get current version from pubspec.yaml +CURRENT_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //') +print_status "Current version: $CURRENT_VERSION" + +# Parse version components +IFS='.' read -ra VERSION_PARTS <<< "${CURRENT_VERSION%%+*}" +MAJOR=${VERSION_PARTS[0]} +MINOR=${VERSION_PARTS[1]} +PATCH=${VERSION_PARTS[2]} + +# Determine release type +RELEASE_TYPE=${1:-patch} + +case $RELEASE_TYPE in + major) + NEW_MAJOR=$((MAJOR + 1)) + NEW_MINOR=0 + NEW_PATCH=0 + ;; + minor) + NEW_MAJOR=$MAJOR + NEW_MINOR=$((MINOR + 1)) + NEW_PATCH=0 + ;; + patch) + NEW_MAJOR=$MAJOR + NEW_MINOR=$MINOR + NEW_PATCH=$((PATCH + 1)) + ;; + *) + print_error "Invalid release type. Use: major, minor, or patch" + exit 1 + ;; +esac + +NEW_VERSION="$NEW_MAJOR.$NEW_MINOR.$NEW_PATCH" +TAG_VERSION="v$NEW_VERSION" + +print_status "New version: $NEW_VERSION" +print_status "Tag version: $TAG_VERSION" + +echo +read -p "Do you want to create release $TAG_VERSION? (y/N): " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + print_warning "Release cancelled" + exit 0 +fi + +# Get current build number +CURRENT_BUILD=$(echo "$CURRENT_VERSION" | awk -F'+' '{print $2}') +if [ -z "$CURRENT_BUILD" ]; then + CURRENT_BUILD=1 +fi +NEW_BUILD=$((CURRENT_BUILD + 1)) + +# Update pubspec.yaml with new version and incremented build number +print_status "Updating pubspec.yaml to version: $NEW_VERSION+$NEW_BUILD" +sed -i.bak "s/^version: .*/version: $NEW_VERSION+$NEW_BUILD/" pubspec.yaml +rm pubspec.yaml.bak + +# Commit changes +print_status "Committing changes..." +git add pubspec.yaml +git commit -m "chore: bump version to $NEW_VERSION" + +git push origin main + +# Create and push tag +print_status "Creating tag $TAG_VERSION..." +git tag -a "$TAG_VERSION" -m "Release $TAG_VERSION" +git push origin "$TAG_VERSION" + +print_status "Release $TAG_VERSION created and pushed! CI will handle the build and GitHub release." \ No newline at end of file