Author: Adam <git@apiote.xyz>
freeze api (arex); add readme, code of conduct and license
%!v(PANIC=String method: strings: negative Repeat count)
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc new file mode 100644 index 0000000000000000000000000000000000000000..ff737edc4d86b1c368dfaa7c44ab75554e379e25 --- /dev/null +++ b/CHANGELOG.adoc @@ -0,0 +1,76 @@ += Changelog + +All notable changes to this project will be documented in this file. + +The format is based on https://keepachangelog.com/en/1.0.0/[Keep a Changelog], but uses AsciiDoc instead of Markdown, +and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Versioning]. + +== Unreleased + +* Searching lines +* Travel planning +* Offline timetable +* Alerts + +== [3.0] – 2023-04-dd + +=== Changed + +* completely new architecture + +== [2.2.2] – 2019-03-11 + +=== Changed + +* drop HTML formatting in PEKA messages + +== [2.2.1] – 2019-03-04 + +=== Changed + +* white icons (low floor, tickets) in night mode + +== [2.2.0] – 2019-02-26 + +=== Added + +* showing low floor and ticket checkouts in VM departures + +=== Changed + +* departures empty state is semi-transparent + +== [2.1] – 2019-02-04 + +=== Added + +* showing empty search result +* loading in shed selection and stop screen +* VM messages + +=== Changed + +* search bar +* empty departures state +* ‘now’ departure is ‘in a moment’ if the vehicle is not on-stop +* sorting departures: on-stop at the top +* sorting search results by similarity + +== [2.0] – 2018-09-21 + +=== Added + +* official timetable from ZTM + +=== Changed + +* VM can be used without offline timetable +* offline timetable uses exact dates (instead of workdays/saturdays/holidays) +* VM is quicker and is more reliable (as it’s computed in the same way as offline departures) +* favourites rewritten from scratch +* app is movable to external storage +* new colours—grey and green—fitting new Poznań style + +=== Fixed + +* multiple bug fixes diff --git a/CODE_OF_CONDUCT b/CODE_OF_CONDUCT new file mode 100644 index 0000000000000000000000000000000000000000..22572bcbc92d27dd11f2f1aacd9de9f9917837a6 --- /dev/null +++ b/CODE_OF_CONDUCT @@ -0,0 +1,53 @@ +Code of Merit + +1. The project creators, lead developers, core team, constitute +the managing members of the project and have final say in every decision +of the project, technical or otherwise, including overruling previous decisions. +There are no limitations to this decisional power. + +2. Contributions are an expected result of your membership on the project. +Don't expect others to do your work or help you with your work forever. + +3. All members have the same opportunities to seek any challenge they want +within the project. + +4. Authority or position in the project will be proportional +to the accrued contribution. Seniority must be earned. + +5. Software is evolutive: the better implementations must supersede lesser +implementations. Technical advantage is the primary evaluation metric. + +6. This is a space for technical prowess; topics outside of the project +will not be tolerated. + +7. Non technical conflicts will be discussed in a separate space. Disruption +of the project will not be allowed. + +8. Individual characteristics, including but not limited to, +body, sex, sexual preference, race, language, religion, nationality, +or political preferences are irrelevant in the scope of the project and +will not be taken into account concerning your value or that of your contribution +to the project. + +9. Discuss or debate the idea, not the person. + +10. There is no room for ambiguity: Ambiguity will be met with questioning; +further ambiguity will be met with silence. It is the responsibility +of the originator to provide requested context. + +11. If something is illegal outside the scope of the project, it is illegal +in the scope of the project. This Code of Merit does not take precedence over +governing law. + +12. This Code of Merit governs the technical procedures of the project not the +activities outside of it. + +13. Participation on the project equates to agreement of this Code of Merit. + +14. No objectives beyond the stated objectives of this project are relevant +to the project. Any intent to deviate the project from its original purpose +of existence will constitute grounds for remedial action which may include +expulsion from the project. + +This document is adapted from the Code of Merit, version 1.0. +See: https://codeofmerit.org/. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..f288702d2fa16d3cdf0035b15a9fcbc552cd88e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + 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. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU 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 <https://www.gnu.org/licenses/>. + +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: + + <program> Copyright (C) <year> <name of author> + 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 +<https://www.gnu.org/licenses/>. + + 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 +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000000000000000000000000000000000000..4e353675fa1e935bf269e062854a660da2f9e2b7 --- /dev/null +++ b/README.adoc @@ -0,0 +1,61 @@ +<<<<<<< HEAD += Bimba +apiote <me@apiote.xyz> +v3.0 2023-mm-dd +:toc: + +Bimba is a FLOSS public transport passenger companion; a timetable in your pocket. + +Bimba can be found on https://floss.social/@bimba[Mastodon] + +== Name + +Bimba is pronounced BEEM-bah [ˈbimba]. +The name is Poznań subdialect for ‘tram’. + +== Installing + +Preffered method is to download Bimba from F-Droid: + +[link=https://f-droid.org/packages/xyz.apiote.bimba.czwek] +image::https://f-droid.org/badge/get-it-on.png[Get it on F-Droid,207,80] + +== Contribute + +This project uses The Code of Merit, which is available as CODE_OF_CONDUCT file. + +The roadmap is available in `CHANGELOG.adoc` file and—although it’s not set in stone—feature requests are highly discouraged. Contributions, however, are welcome as patches; please send them to `bimba@git.apiote.xyz` using `git send-email`. Patches must include a sign-off to certify agreement to https://developercertificate.org/[Developer Certificate of Origin]. + +All communication—questions, bugs, etc.—should go through the mailing list available at `bimba@git.apiote.xyz`. Note that all communication will be made public at https://asgard.apiote.xyz/. + +== Mirrors + +The canonical repository for this project is https://git.apiote.xyz/Bimba.git it’s mirrored at https://notabug.org/apiote/Bimba + +Mirrors exist solely for the sake of the code and any additional functions provided by third-party services (including but not limited to issues and pull requests) will not be used and will be ignored. + +== License + +---- +Bimba Copyright (c) apiote + +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 <https://www.gnu.org/licenses/>. +---- + +=== Thanks to… + +* https://github.com/tebriz159 for new logo + +* https://fonts.google.com/icons[Material Icons], © Google Apache 2.0 +* https://github.com/mancj/MaterialSearchBar[Search bar], © mancj MIT diff --git a/app/build.gradle b/app/build.gradle index 1a662a0534d8f697626dc54fd004c57425213f57..36e41bb58dd29f62cde98015b094c18d02b3bf2b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,13 +1,14 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.parcelize" } android { compileSdk 33 defaultConfig { - applicationId "ml.adamsprogs.bimba" + applicationId "xyz.apiote.bimba.czwek" minSdk 21 targetSdk 33 versionCode 20 @@ -25,6 +26,7 @@ } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true } kotlinOptions { jvmTarget = '1.8' @@ -32,7 +34,7 @@ } buildFeatures { viewBinding true } - namespace 'ml.adamsprogs.bimba' + namespace 'xyz.apiote.bimba.czwek' buildToolsVersion '33.0.1' } @@ -50,6 +52,8 @@ implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.core:core-splashscreen:1.0.0' implementation 'com.google.openlocationcode:openlocationcode:1.0.4' implementation 'org.osmdroid:osmdroid-android:6.1.14' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' + implementation 'org.yaml:snakeyaml:2.0' implementation project(path: ':fruchtfleisch') testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9df27e64cd94320f13c1b8e8bf9a32115899eaa9..ba54cbb104e4c1c2a15a22f4172846243d9d6206 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools"> + xmlns:tool="http://schemas.android.com/tools"> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.INTERNET" /> @@ -8,7 +8,7 @@<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <application - android:name=".Bimba" + android:name="xyz.apiote.bimba.czwek.Bimba" android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" @@ -17,22 +17,22 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Bimba.Style" - tools:targetApi="31"> + tool:targetApi="31"> <activity - android:name=".settings.ServerChooserActivity" + android:name="xyz.apiote.bimba.czwek.settings.ServerChooserActivity" android:exported="false"> <meta-data android:name="android.app.lib_name" android:value="" /> </activity> <activity - android:name=".onboarding.OnboardingActivity"> + android:name="xyz.apiote.bimba.czwek.onboarding.OnboardingActivity"> </activity> <activity - android:name=".settings.feeds.FeedChooserActivity" + android:name="xyz.apiote.bimba.czwek.settings.feeds.FeedChooserActivity" android:exported="false" /> <activity - android:name=".onboarding.FirstRunActivity" + android:name="xyz.apiote.bimba.czwek.onboarding.FirstRunActivity" android:exported="true" android:theme="@style/Theme.Bimba.Splash"> <intent-filter> @@ -42,7 +42,7 @@ </intent-filter> </activity> <activity - android:name=".departures.DeparturesActivity" + android:name="xyz.apiote.bimba.czwek.departures.DeparturesActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.VIEW" /> @@ -57,11 +57,11 @@ </intent-filter> </activity> <activity - android:name=".search.ResultsActivity" + android:name="xyz.apiote.bimba.czwek.search.ResultsActivity" android:exported="false" android:label="@string/title_activity_results" /> <activity - android:name=".dashboard.MainActivity" + android:name="xyz.apiote.bimba.czwek.dashboard.MainActivity" android:exported="false" android:windowSoftInputMode="adjustPan" /> </application> diff --git a/app/src/main/java/ml/adamsprogs/bimba/Bimba.kt b/app/src/main/java/ml/adamsprogs/bimba/Bimba.kt deleted file mode 100644 index 05e8f843852389cce329eb2f4ddd7f146eed4732..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/Bimba.kt +++ /dev/null @@ -1,20 +0,0 @@ -package ml.adamsprogs.bimba - -import org.osmdroid.config.Configuration -import java.io.File - -class Bimba : android.app.Application() { - override fun onCreate() { - super.onCreate() - Configuration.getInstance() - .let { config -> - config.load( - applicationContext, - applicationContext.getSharedPreferences("shp", MODE_PRIVATE) - ) - config.osmdroidBasePath = File(applicationContext.cacheDir.absolutePath, "osmdroid") - - config.osmdroidTileCache = File(config.osmdroidBasePath.absolutePath, "tile") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt deleted file mode 100644 index 173e954ef3625e71ddcb0c376c7ca2929aea3f31..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt +++ /dev/null @@ -1,148 +0,0 @@ -package ml.adamsprogs.bimba.api - -import android.content.Context -import android.content.Context.MODE_PRIVATE -import android.net.ConnectivityManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import ml.adamsprogs.bimba.R -import java.io.IOException -import java.io.InputStream -import java.net.HttpURLConnection -import java.net.MalformedURLException -import java.net.URL -import java.net.URLEncoder - -// todo [3.1] constants - -data class Server(val host: String, val token: String, val feeds: String) { - companion object { - fun get(context: Context): Server { - val preferences = context.getSharedPreferences("shp", MODE_PRIVATE) - val host = preferences.getString("host", "bimba.apiote.xyz")!! - return Server( - host, preferences.getString("token", "")!!, - preferences.getString("${host}_feeds", "")!! - ) - } - } -} - -data class Result(val stream: InputStream?, val error: Error?) - -data class Error(val statusCode: Int, val stringResource: Int, val imageResource: Int) - -suspend fun getFeeds(cm: ConnectivityManager, server: Server): Result { - return try { - rawRequest( - URL("${hostWithScheme(server.host)}/api/"), - server, - cm - ) - } catch(_: MalformedURLException) { - Result(null, Error(0, R.string.error_url, R.drawable.error_url)) - } -} - -suspend fun queryItems( - cm: ConnectivityManager, - server: Server, - query: String, - limit: Int? = null -): Result { - val params = mutableMapOf("q" to query) - if (limit != null) { - params["limit"] = limit.toString() - } - return request(server, "items", params, cm) -} - -suspend fun locateItems(cm: ConnectivityManager, server: Server, near: Position): Result { - return request(server, "items", mapOf("near" to near.toString()), cm) -} - -suspend fun getLocatablesIn( - cm: ConnectivityManager, - server: Server, - bl: Position, - tr: Position -): Result { - return request(server, "locatables", mapOf("lb" to bl.toString(), "rt" to tr.toString()), cm) -} - -suspend fun getDepartures( - cm: ConnectivityManager, - server: Server, - stop: String, - line: String? = null -): Result { - val params = mutableMapOf("code" to stop) - if (line != null) { - params["line"] = line - } - return request(server, "departures", params, cm) -} - -suspend fun rawRequest(url: URL, server: Server, cm: ConnectivityManager): Result { - // todo[api-freeze] request and check api version - @Suppress("DEPRECATION") // fixme later(API_29, API_23) https://developer.android.com/reference/android/net/ConnectivityManager#getActiveNetwork() - if (cm.activeNetworkInfo == null) { - return Result(null, Error(0, R.string.error_offline, R.drawable.error_net)) - } - return withContext(Dispatchers.IO) { - val c = (url.openConnection() as HttpURLConnection).apply { - setRequestProperty("X-Bimba-Token", server.token) - } - try { - if (c.responseCode == 200) { - Result(c.inputStream, null) - } else { - val (string, image) = when (c.responseCode) { - 400 -> Pair(R.string.error_400, R.drawable.error_app) - 401 -> Pair(R.string.error_401, R.drawable.error_sec) - 403 -> Pair(R.string.error_403, R.drawable.error_sec) - 404 -> Pair(R.string.error_404, R.drawable.error_search) - 429 -> Pair(R.string.error_429, R.drawable.error_limit) - 500 -> Pair(R.string.error_50x, R.drawable.error_server) - 502 -> Pair(R.string.error_50x, R.drawable.error_server) - 503 -> Pair(R.string.error_50x, R.drawable.error_server) - 504 -> Pair(R.string.error_50x, R.drawable.error_server) - else -> Pair(R.string.error_unknown, R.drawable.error_other) - } - Result(c.errorStream, Error(c.responseCode, string, image)) - } - } catch (e: IOException) { - Result(null, Error(0, R.string.error_connecting, R.drawable.error_server)) - } - } -} - -suspend fun request( - server: Server, - resource: String, - params: Map<String, String>, - cm: ConnectivityManager -): Result { - return withContext(Dispatchers.IO) { - val url = URL( // todo [3.1] scheme, host, path, constructed query - "${hostWithScheme(server.host)}/api/${server.feeds}/$resource${ - params.map { - "${it.key}=${ - URLEncoder.encode( - it.value, - "utf-8" - ) - }" - }.joinToString("&", "?") - }" - ) - rawRequest(url, server, cm) - } -} - -fun hostWithScheme(host: String): String = - if (host.startsWith("http://") or host.startsWith("https://")) { - host - } else { - "https://$host" - } \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt deleted file mode 100644 index 95a8a2627e8cc9adb3605a0d5f179b261ba9f673..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt +++ /dev/null @@ -1,187 +0,0 @@ -package ml.adamsprogs.bimba.api - -import xyz.apiote.fruchtfleisch.Reader -import java.io.InputStream - -interface DeparturesResponse { - companion object { - fun unmarshal(stream: InputStream): DeparturesResponse { - val reader = Reader(stream) - return when (reader.readUInt().toULong()) { - 0UL -> { - ErrorResponse.unmarshal(stream) - } - 1UL -> { - DeparturesSuccess.unmarshal(stream) - } - else -> { - TODO("[api-freeze] throw unknown tag") - } - } - } - } -} - -data class DeparturesSuccess( - val alerts: List<Alert>, - val departures: List<Departure>, - val stop: Stop -) : DeparturesResponse { - companion object { - fun unmarshal(stream: InputStream): DeparturesSuccess { - val alerts = mutableListOf<Alert>() - val departures = mutableListOf<Departure>() - - val reader = Reader(stream) - val alertsNum = reader.readUInt().toULong() - for (i in 0UL until alertsNum) { - val alert = Alert.unmarshal(stream) - alerts.add(alert) - } - val departuresNum = reader.readUInt().toULong() - for (i in 0UL until departuresNum) { - val departure = Departure.unmarshal(stream) - departures.add(departure) - } - - return DeparturesSuccess(alerts, departures, Stop.unmarshal(stream)) - } - } -} - -interface ItemsResponse { - companion object { - fun unmarshal(stream: InputStream): ItemsResponse { - val reader = Reader(stream) - return when (reader.readUInt().toULong()) { - 0UL -> { - ErrorResponse.unmarshal(stream) - } - 1UL -> { - ItemsSuccess.unmarshal(stream) - } - else -> { - TODO("[api-freeze] throw unknown tag") - } - } - } - } -} - -data class ItemsSuccess(val items: List<Item>) : ItemsResponse { - companion object { - fun unmarshal(stream: InputStream): ItemsSuccess { - val items = mutableListOf<Item>() - val reader = Reader(stream) - val itemsNum = reader.readUInt().toULong() - for (i in 0UL until itemsNum) { - when (reader.readUInt().toULong()) { - 0UL -> { - items.add(Stop.unmarshal(stream)) - } - 1UL -> { - items.add(Line.unmarshal(stream)) - } - else -> { - TODO("[api-freeze] throw unknown tag") - } - } - } - return ItemsSuccess(items) - } - } -} - -interface FeedsResponse { - companion object { - fun unmarshal(stream: InputStream): FeedsResponse { - val reader = Reader(stream) - return when (reader.readUInt().toULong()) { - 0UL -> { - ErrorResponse.unmarshal(stream) - } - 1UL -> { - FeedsSuccess.unmarshal(stream) - } - else -> { - TODO("[api-freeze] throw unknown tag") - } - } - } - } -} - -data class FeedsSuccess( - val adminContact: String, - val rateLimited: Boolean, - val private: Boolean, - val feeds: List<FeedInfo> -) : FeedsResponse { - companion object { - fun unmarshal(stream: InputStream): FeedsSuccess { - val feeds = mutableListOf<FeedInfo>() - val reader = Reader(stream) - val adminContact = reader.readString() - val rateLimited = reader.readBoolean() - val private = reader.readBoolean() - val itemsNum = reader.readUInt().toULong() - for (i in 0UL until itemsNum) { - feeds.add(FeedInfo.unmarshal(stream)) - } - return FeedsSuccess(adminContact, rateLimited, private, feeds) - } - } -} - -interface LocatablesResponse { - companion object { - fun unmarshal(stream: InputStream): LocatablesResponse { - val reader = Reader(stream) - return when (reader.readUInt().toULong()) { - 0UL -> { - ErrorResponse.unmarshal(stream) - } - 1UL -> { - LocatablesSuccess.unmarshal(stream) - } - else -> { - TODO("[api-freeze] throw unknown tag") - } - } - } - } -} - -data class LocatablesSuccess(val locatables: List<Locatable>) : LocatablesResponse { - companion object { - fun unmarshal(stream: InputStream): LocatablesSuccess { - val locatables = mutableListOf<Locatable>() - val reader = Reader(stream) - val itemsNum = reader.readUInt().toULong() - for (i in 0UL until itemsNum) { - when (reader.readUInt().toULong()) { - 0UL -> { - locatables.add(Stop.unmarshal(stream)) - } - 1UL -> { - locatables.add(Vehicle.unmarshal(stream)) - } - else -> { - TODO("[api-freeze] throw unknown tag") - } - } - } - return LocatablesSuccess(locatables) - } - } -} - -data class ErrorResponse(val field: String, val message: String) : ItemsResponse, - DeparturesResponse, FeedsResponse, LocatablesResponse { - companion object { - fun unmarshal(stream: InputStream): ErrorResponse { - val reader = Reader(stream) - return ErrorResponse(reader.readString(), reader.readString()) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt deleted file mode 100644 index 4e713cc4d4ea1124edf8fe0d3abc62bf12e04d96..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt +++ /dev/null @@ -1,649 +0,0 @@ -package ml.adamsprogs.bimba.api - -import android.content.Context -import android.graphics.* -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.graphics.drawable.LayerDrawable -import android.text.format.DateFormat -import android.text.format.DateUtils -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.graphics.ColorUtils.HSLToColor -import androidx.core.graphics.drawable.toBitmap -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.dpToPixel -import ml.adamsprogs.bimba.dpToPixelI -import xyz.apiote.fruchtfleisch.Reader -import java.io.InputStream -import java.util.* -import java.util.zip.Adler32 -import kotlin.math.abs -import kotlin.math.pow - -data class Position( - val latitude: Double, - val longitude: Double -) { - fun isZero(): Boolean { - return latitude == 0.0 && longitude == 0.0 - } - - override fun toString(): String = "$latitude,$longitude" - - companion object { - fun unmarshal(stream: InputStream): Position { - val reader = Reader(stream) - return Position( - reader.readFloat64(), - reader.readFloat64() - ) - } - } -} - -data class DateTime( - val year: Int, - val month: Int, - val day: Int, - val hour: Int, - val minute: Int, - val second: Int, - val offsetSign: Int, - val offsetH: Int, - val offsetM: Int -) { - fun toString(context: Context): String { - return DateFormat.getDateFormat(context).format(GregorianCalendar(year, month, day)) - } - - companion object { - fun of(s: String): DateTime { - val (oH, oM) = if (s[19] == 'Z') { - arrayOf(0, 0) - } else { - arrayOf(Integer.parseInt(s.substring(20, 22)), Integer.parseInt(s.substring(23, 25))) - } - val oS = when (s[19]) { - 'Z' -> 0 - '+' -> 1 - '-' -> -1 - else -> TODO("Throw invalid") - } - return DateTime( - Integer.parseInt(s.substring(0, 4)), - Integer.parseInt(s.substring(5, 7)), - Integer.parseInt(s.substring(8, 10)), - Integer.parseInt(s.substring(11, 13)), - Integer.parseInt(s.substring(14, 16)), - Integer.parseInt(s.substring(17, 19)), - oS, oH, oM - ) - } - } -} - -data class FeedInfo( - val name: String, - val id: String, - val attribution: String, - val description: String, - val lastUpdate: DateTime -) { - companion object { - fun unmarshal(stream: InputStream): FeedInfo { - val reader = Reader(stream) - return FeedInfo( - reader.readString(), - reader.readString(), - reader.readString(), - reader.readString(), - DateTime.of(reader.readString()) - ) - } - } -} - -data class Alert( - val header: String, - val Description: String, - val Url: String, - val Cause: UInt, - val Effect: UInt -) { - companion object { - fun unmarshal(stream: InputStream): Alert { - val reader = Reader(stream) - val header = reader.readString() - val description = reader.readString() - val url = reader.readString() - val cause = reader.readU32() - val effect = reader.readU32() - return Alert(header, description, url, cause, effect) - } - } -} - -data class Time( - val Hour: UInt, - val Minute: UInt, - val Second: UInt, - val DayOffset: Byte, - val Zone: String -) { - companion object { - fun unmarshal(stream: InputStream): Time { - val reader = Reader(stream) - return Time( - reader.readUInt().toULong().toUInt(), - reader.readUInt().toULong().toUInt(), - reader.readUInt().toULong().toUInt(), - reader.readI8(), - reader.readString() - ) - } - } -} - -data class Colour(val R: UByte, val G: UByte, val B: UByte) { - companion object { - fun unmarshal(stream: InputStream): Colour { - val reader = Reader(stream) - return Colour( - reader.readU8(), - reader.readU8(), - reader.readU8() - ) - } - } - - fun toInt(): Int { - var rgb = 0xff - rgb = (rgb shl 8) + R.toInt() - rgb = (rgb shl 8) + G.toInt() - rgb = (rgb shl 8) + B.toInt() - return rgb - } -} - -data class Vehicle( - val ID: String, - val Position: Position, - val Capabilities: UShort, - val Speed: Float, - val Line: LineStub, - val Headsign: String, - val CongestionLevel: UByte, - val OccupancyStatus: UByte -) : Locatable { - enum class Capability(val bit: UShort) { - RAMP(0b0001u), - LOW_FLOOR(0b0010u), - LOW_ENTRY(0b0001_0000_0000u), - AC(0b0100u), - BIKE(0b1000u), - VOICE(0b0001_0000u), - TICKET_MACHINE(0b0010_0000u), - TICKET_DRIVER(0b0100_0000u), - USB_CHARGING(0b1000_0000u) - } - - override fun id(): String = ID - - override fun icon(context: Context, scale: Float): Drawable { - return BitmapDrawable(context.resources, Line.icon(context, scale)) - } - - override fun location(): Position = Position - - fun congestion(context: Context): String { - return when (CongestionLevel.toUInt()) { // todo enum - 0u -> context.getString(R.string.congestion_unknown) - 1u -> context.getString(R.string.congestion_smooth) - 2u -> context.getString(R.string.congestion_stop_and_go) - 3u -> context.getString(R.string.congestion_congestion) - 4u -> context.getString(R.string.congestion_jams) - else -> TODO("throw invalid congestion") - } - } - - fun occupancy(context: Context): String { - return when (OccupancyStatus.toUInt()) { // todo enum - 0u -> context.getString(R.string.occupancy_unknown) - 1u -> context.getString(R.string.occupancy_empty) - 2u -> context.getString(R.string.occupancy_many_seats) - 3u -> context.getString(R.string.occupancy_few_seats) - 4u -> context.getString(R.string.occupancy_standing_only) - 5u -> context.getString(R.string.occupancy_crowded) - 6u -> context.getString(R.string.occupancy_full) - 7u -> context.getString(R.string.occupancy_wont_let) - else -> TODO("throw invalid occupancy") - } - } - - companion object { - fun unmarshal(stream: InputStream): Vehicle { - val reader = Reader(stream) - return Vehicle( - reader.readString(), - Position.unmarshal(stream), - reader.readU16(), - reader.readFloat32(), - LineStub.unmarshal(stream), - reader.readString(), - reader.readU8(), - reader.readU8() - ) - } - } - - fun getCapability(field: Capability): Boolean { - return Capabilities.and(field.bit) != (0).toUShort() - } -} - -data class LineStub( - val colour: Colour, - val type: LineType, - val name: String -) : LineAbstract { - companion object { - fun unmarshal(stream: InputStream): LineStub { - val reader = Reader(stream) - val colour = Colour.unmarshal(stream) - val type = reader.readUInt() - val name = reader.readString() - return LineStub(name = name, colour = colour, type = LineType.of(type.toULong().toUInt())) - } - } - - fun icon(context: Context, scale: Float = 1f): Bitmap { - return super.icon(context, type, colour, scale) - } -} - -data class Departure( - val ID: String, - val line: LineStub, - val headsign: String, - val time: Time, - val status: UByte, - val isRealtime: Boolean, - val stopOrder: String, - val vehicle: Vehicle, - val boarding: UByte -) { - - fun statusText(context: Context?): String { - val now = Calendar.getInstance() - val departureTime = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, this@Departure.time.Hour.toInt()) - set(Calendar.MINUTE, this@Departure.time.Minute.toInt()) - set(Calendar.SECOND, this@Departure.time.Second.toInt()) - set( - Calendar.ZONE_OFFSET, - TimeZone.getTimeZone(this@Departure.time.Zone).getOffset(now.timeInMillis) - ) - roll(Calendar.DAY_OF_MONTH, this@Departure.time.DayOffset.toInt()) - } - return when (status.toInt()) { - 0 -> DateUtils.getRelativeTimeSpanString( - departureTime.timeInMillis, - now.timeInMillis, - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE - ) - .toString() - 1 -> context?.getString(R.string.departure_momentarily) ?: "momentarily" - 2 -> context?.getString(R.string.departure_now) ?: "now" - 3 -> context?.getString(R.string.departure_departed) ?: "departed" - else -> TODO("throw invalid") - } - } - - fun timeString(context: Context): String { - return if (isRealtime) { - context.getString( - R.string.at_time_realtime, - time.Hour.toInt(), - time.Minute.toInt(), - time.Second.toInt() - ) - } else { - context.getString(R.string.at_time, time.Hour.toInt(), time.Minute.toInt()) - } - } - - fun boardingText(context: Context): String { - // todo [3.x] probably should take into account (on|off)-boarding only, on demand - return when { - (boarding.and(0b1010u) != (0b0).toUByte()) -> context.getString(R.string.on_demand) - (boarding.and(0b0101u) == (0b0101).toUByte()) -> context.getString(R.string.no_boarding) - (boarding.and(0b0100u) == (0b0100).toUByte()) -> context.getString(R.string.on_boarding) - (boarding.and(0b0001u) == (0b0001).toUByte()) -> context.getString(R.string.off_boarding) - else -> context.getString(R.string.boarding) - } - } - - companion object { - fun unmarshal(stream: InputStream): Departure { - val reader = Reader(stream) - val id = reader.readString() - val line = LineStub.unmarshal(stream) - val headsign = reader.readString() - val time = Time.unmarshal(stream) - val status = reader.readU8() - val isRealtime = reader.readBoolean() - val stopOrder = reader.readString() - val vehicle = Vehicle.unmarshal(stream) - val boarding = reader.readU8() - return Departure(id, line, headsign, time, status, isRealtime, stopOrder, vehicle, boarding) - } - } -} - -interface Item -interface Locatable { - fun icon(context: Context, scale: Float = 1f): Drawable - fun location(): Position - fun id(): String -} - -data class Stop( - val code: String, - val zone: String, - val position: Position, - val changeOptions: List<ChangeOption>, - val name: String -) : Item, Locatable { - override fun icon(context: Context, scale: Float): Drawable { - val saturationArray = arrayOf(0.5f, 0.65f, 0.8f) - val sal = saturationArray.size - val lightnessArray = arrayOf(.5f) - val lal = lightnessArray.size - val md = Adler32().let { - it.update(name.toByteArray()) - it.value - } - val h = md % 359f - val s = saturationArray[(md / 360 % sal).toInt()] - val l = lightnessArray[(md / 360 / sal % lal).toInt()] - val fg = AppCompatResources.getDrawable(context, R.drawable.stop) - val bg = - AppCompatResources.getDrawable(context, R.drawable.stop_bg)!!.mutate() - .apply { - setTint(HSLToColor(arrayOf(h, s, l).toFloatArray())) - } - return BitmapDrawable( - context.resources, - LayerDrawable(arrayOf(bg, fg)).mutate() - .toBitmap(dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888) - ) - } - - override fun id(): String = code - - override fun location(): Position = position - - override fun toString(): String { - var result = "$name ($code) [$zone] $position\n" - for (chOpt in changeOptions) - result += "${chOpt.line} → ${chOpt.headsign}\n" - return result - } - - fun changeOptions(context: Context): Pair<String, String> { - return Pair(changeOptions.groupBy { it.line } - .map { Pair(it.key, it.value.joinToString { co -> co.headsign }) } - .joinToString { - context.getString( - R.string.vehicle_headsign, - it.first, - it.second - ) - }, - changeOptions.groupBy { it.line } - .map { Pair(it.key, it.value.joinToString { co -> co.headsign }) } - .joinToString { - context.getString( - R.string.vehicle_headsign_content_description, - it.first, - it.second - ) - }) - } - - companion object { - fun unmarshal(stream: InputStream): Stop { - val reader = Reader(stream) - val code = reader.readString() - val zone = reader.readString() - val position = Position.unmarshal(stream) - val chOptionsNum = reader.readUInt().toULong() - val changeOptions = mutableListOf<ChangeOption>() - for (i in 0UL until chOptionsNum) { - changeOptions.add(ChangeOption.unmarshal(stream)) - } - val name = reader.readString() - return Stop( - name = name, - code = code, - zone = zone, - position = position, - changeOptions = changeOptions - ) - } - } -} - -interface LineAbstract { - fun textColour(c: Colour): Int { - val black = relativeLuminance(Colour(0u, 0u, 0u)) + .05 - val white = relativeLuminance(Colour(255u, 255u, 255u)) + .05 - val colour = relativeLuminance(c) + .05 - return if ((white / colour) > (colour / black)) { - Color.WHITE - } else { - Color.BLACK - } - } - - private fun relativeLuminance(colour: Colour): Double { - val r = fromSRGB(colour.R.toDouble() / 0xff) - val g = fromSRGB(colour.G.toDouble() / 0xff) - val b = fromSRGB(colour.B.toDouble() / 0xff) - return 0.2126 * r + 0.7152 * g + 0.0722 * b - } - - private fun fromSRGB(part: Double): Double { - return if (part <= 0.03928) { - part / 12.92 - } else { - ((part + 0.055) / 1.055).pow(2.4) - } - } - - fun icon(context: Context, type: LineType, colour: Colour, scale: Float): Bitmap { - val drawingBitmap = Bitmap.createBitmap( - dpToPixelI(24f / scale), - dpToPixelI(24f / scale), - Bitmap.Config.ARGB_8888 - ) - val canvas = Canvas(drawingBitmap) - - canvas.drawPath( - getSquirclePath( - dpToPixel(.8f / scale), - dpToPixel(.8f / scale), - dpToPixelI(11.2f / scale) - ), Paint().apply { color = textColour(colour) }) - canvas.drawPath( - getSquirclePath( - dpToPixel(1.6f / scale), - dpToPixel(1.6f / scale), - dpToPixelI(10.4f / scale) - ), Paint().apply { color = colour.toInt() }) - - val iconID = when (type) { - LineType.BUS -> R.drawable.bus_black - LineType.TRAM -> R.drawable.tram_black - LineType.UNKNOWN -> R.drawable.vehicle_black - } - val icon = - AppCompatResources.getDrawable(context, iconID)?.mutate() - ?.apply { - setTint(textColour(colour)) - }?.toBitmap(dpToPixelI(19.2f / scale), dpToPixelI(19.2f / scale), Bitmap.Config.ARGB_8888) - canvas.drawBitmap( - icon!!, - dpToPixel(2.4f / scale), - dpToPixel(2.4f / scale), - Paint() - ) - return drawingBitmap - } - - private fun getSquirclePath( - left: Float, - top: Float, - radius: Int - ): Path { - val radiusToPow = (radius * radius * radius).toDouble() - val path = Path() - path.moveTo(-radius.toFloat(), 0f) - for (x in -radius..radius) path.lineTo( - x.toFloat(), - Math.cbrt(radiusToPow - abs(x * x * x)).toFloat() - ) - for (x in radius downTo -radius) path.lineTo( - x.toFloat(), - -Math.cbrt(radiusToPow - abs(x * x * x)).toFloat() - ) - path.close() - val matrix = Matrix() - matrix.postTranslate((left + radius), (top + radius)) - path.transform(matrix) - return path - } -} - -data class Line( - val colour: Colour, - val type: LineType, - val headsignsThere: List<String>, - val headsignsBack: List<String>, - val graphThere: LineGraph, - val graphBack: LineGraph, - val name: String -) : Item, LineAbstract { - override fun toString(): String { - return "$name ($type) [$colour]\n→ [${headsignsThere.joinToString()}]\n→ [${headsignsBack.joinToString()}]\n" - } - - fun icon(context: Context, scale: Float = 1f): Bitmap { - return super.icon(context, type, colour, scale) - } - - companion object { - fun unmarshal(stream: InputStream): Line { - val reader = Reader(stream) - val colour = Colour.unmarshal(stream) - val type = reader.readUInt() - val headsignsThereNum = reader.readUInt().toULong() - val headsignsThere = mutableListOf<String>() - for (i in 0UL until headsignsThereNum) { - headsignsThere.add(reader.readString()) - } - val headsignsBackNum = reader.readUInt().toULong() - val headsignsBack = mutableListOf<String>() - for (i in 0UL until headsignsBackNum) { - headsignsBack.add(reader.readString()) - } - val graphThere = LineGraph.unmarshal(stream) - val graphBack = LineGraph.unmarshal(stream) - val name = reader.readString() - return Line( - name = name, colour = colour, type = LineType.of(type.toULong().toUInt()), - headsignsThere = headsignsThere, headsignsBack = headsignsBack, graphThere = graphThere, - graphBack = graphBack - ) - } - } -} - -enum class LineType { - TRAM, BUS, UNKNOWN; - - companion object { - fun of(type: UInt): LineType { - return when (type) { - 0U -> valueOf("TRAM") - 3U -> valueOf("BUS") - else -> valueOf("UNKNOWN") - } - } - } -} - -data class ChangeOption(val line: String, val headsign: String) { - companion object { - fun unmarshal(stream: InputStream): ChangeOption { - val reader = Reader(stream) - return ChangeOption(line = reader.readString(), headsign = reader.readString()) - } - } -} - -data class LineGraph( - val stops: List<StopStub>, - val nextNodes: Map<Long, List<Long>>, - val prevNodes: Map<Long, List<Long>> -) { - companion object { - fun unmarshal(stream: InputStream): LineGraph { - val reader = Reader(stream) - val stopsNum = reader.readUInt().toULong() - val stops = mutableListOf<StopStub>() - for (i in 0UL until stopsNum) { - stops.add(StopStub.unmarshal(stream)) - } - val nextNodesNum = reader.readUInt().toULong() - val nextNodes = mutableMapOf<Long, List<Long>>() - for (i in 0UL until nextNodesNum) { - val from = reader.readInt().toLong() - val toNum = reader.readUInt().toULong() - val to = mutableListOf<Long>() - for (j in 0UL until toNum) { - to.add(reader.readInt().toLong()) - } - nextNodes[from] = to - } - val prevNodesNum = reader.readUInt().toULong() - val prevNodes = mutableMapOf<Long, List<Long>>() - for (i in 0UL until prevNodesNum) { - val from = reader.readInt().toLong() - val toNum = reader.readUInt().toULong() - val to = mutableListOf<Long>() - for (j in 0UL until toNum) { - to.add(reader.readInt().toLong()) - } - prevNodes[from] = to - } - - return LineGraph(stops = stops, nextNodes = nextNodes, prevNodes = prevNodes) - } - } -} - -data class StopStub(val name: String, val code: String, val zone: String, val onDemand: Boolean) { - companion object { - fun unmarshal(stream: InputStream): StopStub { - val reader = Reader(stream) - return StopStub( - code = reader.readString(), - name = reader.readString(), - zone = reader.readString(), - onDemand = reader.readBoolean() - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt deleted file mode 100644 index c607543a5709f03412f59efca681844019bf36d2..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt +++ /dev/null @@ -1,202 +0,0 @@ -package ml.adamsprogs.bimba.dashboard - -import android.Manifest -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Bundle -import android.view.View -import android.widget.Toast -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.core.view.WindowCompat -import androidx.core.view.get -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.bottomnavigation.BottomNavigationView -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.api.Item -import ml.adamsprogs.bimba.api.Line -import ml.adamsprogs.bimba.api.Stop -import ml.adamsprogs.bimba.databinding.ActivityMainBinding -import ml.adamsprogs.bimba.search.ResultsActivity -import ml.adamsprogs.bimba.dashboard.ui.home.HomeFragment -import ml.adamsprogs.bimba.dashboard.ui.map.MapFragment -import ml.adamsprogs.bimba.dashboard.ui.voyage.VoyageFragment -import ml.adamsprogs.bimba.departures.DeparturesActivity -import ml.adamsprogs.bimba.settings.ServerChooserActivity -import ml.adamsprogs.bimba.settings.feeds.FeedChooserActivity - - -class MainActivity : AppCompatActivity() { - private lateinit var binding: ActivityMainBinding - private lateinit var locationPermissionRequest: ActivityResultLauncher<Array<String>> - - private lateinit var permissionAsker: Fragment - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - getSharedPreferences("shp", MODE_PRIVATE).edit(true) { - putBoolean("firstRun", false) - } - - supportFragmentManager.registerFragmentLifecycleCallbacks( - object : FragmentLifecycleCallbacks() { - override fun onFragmentViewCreated( - fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle? - ) { - setNavbarIcons(f) - super.onFragmentViewCreated(fm, f, v, savedInstanceState) - } - }, true - ) - - binding.navigationDrawer.setNavigationItemSelectedListener { - when (it.itemId) { - R.id.drawer_servers -> { - startActivity(Intent(this, ServerChooserActivity::class.java)) - } - R.id.drawer_cities -> { - startActivity(Intent(this, FeedChooserActivity::class.java)) - } - } - false - } - - WindowCompat.setDecorFitsSystemWindows(window, false) - val navView: BottomNavigationView = binding.bottomNavigation - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_main) as NavHostFragment - val navController = navHostFragment.navController - navView.setupWithNavController(navController) - - locationPermissionRequest = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - when { - permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false || - permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false -> { - when (permissionAsker) { - is HomeFragment -> { - showResults(ResultsActivity.Mode.MODE_LOCATION) - } - is MapFragment -> { - (permissionAsker as MapFragment).showLocation() - } - } - } - else -> { - // todo(ux,ui) dialog - Toast.makeText(this, "No location access given", Toast.LENGTH_SHORT).show() - } - } - } - } - - @Suppress( - "OVERRIDE_DEPRECATION", - "DEPRECATION" - ) // fixme later https://developer.android.com/reference/androidx/activity/OnBackPressedDispatcher - override fun onBackPressed() { - if (binding.container.isDrawerOpen(binding.navigationDrawer)) { - binding.container.closeDrawer(binding.navigationDrawer) - } else { - super.onBackPressed() - } - } - - fun onNavigationClicked() { - if (binding.container.isDrawerOpen(binding.navigationDrawer)) { - binding.container.closeDrawer(binding.navigationDrawer) - } else { - binding.container.openDrawer(binding.navigationDrawer) - } - } - - fun onGpsClicked(fragment: Fragment) { - when (PackageManager.PERMISSION_GRANTED) { - ContextCompat.checkSelfPermission( - this, - Manifest.permission.ACCESS_COARSE_LOCATION - ) -> { - when (fragment) { - is HomeFragment -> { - showResults(ResultsActivity.Mode.MODE_LOCATION) - } - is MapFragment -> { - fragment.showLocation() - } - } - } - else -> { - permissionAsker = fragment - locationPermissionRequest.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - ) - } - } - } - - fun onSuggestionClicked(item: Item) { - when (item) { - is Stop -> { - val intent = Intent(this, DeparturesActivity::class.java).apply { - putExtra("code", item.code) - putExtra("name", item.name) - } - startActivity(intent) - } - is Line -> { - TODO("[3.1] start line graph activity") - } - } - } - - fun onSearchClicked(text: CharSequence?) { - showResults(ResultsActivity.Mode.MODE_SEARCH, text.toString()) - } - - private fun showResults(mode: ResultsActivity.Mode, query: String = "") { - /* todo [3.1] (ux,low) animation - https://developer.android.com/guide/fragments/animate - https://github.com/raheemadamboev/fab-explosion-animation-app - */ - val intent = Intent(this, ResultsActivity::class.java).apply { - putExtra("mode", mode) - putExtra("query", query) - } - startActivity(intent) - } - - private fun setNavbarIcons(f: Fragment) { - // todo [voyage-planning] - // binding.bottomNavigation.menu[2].setIcon(R.drawable.voyage_outline) - binding.bottomNavigation.menu[1].setIcon(R.drawable.home_outline) - binding.bottomNavigation.menu[0].setIcon(R.drawable.map_outline) - when (f) { - is HomeFragment -> { - binding.bottomNavigation.menu[1].setIcon(R.drawable.home_black) - } - is VoyageFragment -> { - binding.bottomNavigation.menu[2].setIcon(R.drawable.voyage_black) - } - is MapFragment -> { - binding.bottomNavigation.menu[0].setIcon(R.drawable.map_black) - } - else -> { - binding.bottomNavigation.menu[1].setIcon(R.drawable.home_black) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt deleted file mode 100644 index d410a5a2a0735ee7203a81126953ea57b8e23be0..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt +++ /dev/null @@ -1,92 +0,0 @@ -package ml.adamsprogs.bimba.dashboard.ui.home - -import android.content.Context -import android.net.ConnectivityManager -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider -import com.mancj.materialsearchbar.MaterialSearchBar -import com.mancj.materialsearchbar.MaterialSearchBar.BUTTON_NAVIGATION -import ml.adamsprogs.bimba.search.BimbaSuggestionsAdapter -import ml.adamsprogs.bimba.dashboard.MainActivity -import ml.adamsprogs.bimba.api.Item -import ml.adamsprogs.bimba.databinding.FragmentHomeBinding - -// todo [3.1] search: https://github.com/material-components/material-components-android/blob/master/docs/components/Search.md - -class HomeFragment : Fragment() { - private var _binding: FragmentHomeBinding? = null - private val binding get() = _binding!! - - private var lastSuggestions = listOf<Item>() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentHomeBinding.inflate(inflater, container, false) - - val homeViewModel = - ViewModelProvider(this)[HomeViewModel::class.java] - homeViewModel.items.observe(viewLifecycleOwner) { - binding.searchBar.updateLastSuggestions(it) - } - - val root = binding.root - ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.layoutParams = (view.layoutParams as FrameLayout.LayoutParams).apply { - topMargin = insets.top - } - WindowInsetsCompat.CONSUMED - } - - binding.searchBar.lastSuggestions = lastSuggestions - val cm = requireContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - binding.searchBar.addTextChangeListener( - homeViewModel.SearchBarWatcher(requireContext(), cm) - ) - binding.searchBar.setOnSearchActionListener(object : MaterialSearchBar.OnSearchActionListener { - override fun onButtonClicked(buttonCode: Int) { - when (buttonCode) { - BUTTON_NAVIGATION -> { - (context as MainActivity).onNavigationClicked() - } - } - } - - override fun onSearchStateChanged(enabled: Boolean) { - } - - override fun onSearchConfirmed(text: CharSequence?) { - binding.searchBar.clearSuggestions() - (context as MainActivity).onSearchClicked(text) - } - }) - binding.searchBar.setCardViewElevation(0) - binding.searchBar.setCustomSuggestionAdapter(BimbaSuggestionsAdapter(layoutInflater, context) { - binding.searchBar.clearSuggestions() - (context as MainActivity).onSuggestionClicked(it) - }) - - binding.floatingActionButton.setOnClickListener { - binding.searchBar.clearSuggestions() - (context as MainActivity).onGpsClicked(this) - } - // todo [3.1] (ux,low) on searchbar focus && if != '' -> populate suggestions - - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt deleted file mode 100644 index e5674398314ca6c30930d625d5940fc9e48ca464..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt +++ /dev/null @@ -1,71 +0,0 @@ -package ml.adamsprogs.bimba.dashboard.ui.home - -import android.content.Context -import android.net.ConnectivityManager -import android.os.Handler -import android.os.Looper -import android.text.Editable -import android.text.TextWatcher -import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import ml.adamsprogs.bimba.api.* -import java.io.InputStream - -class HomeViewModel : ViewModel() { - private val mutableItems = MutableLiveData<List<Item>>() - val items: LiveData<List<Item>> = mutableItems - - fun getItems(cm: ConnectivityManager, server: Server, query: String) { - viewModelScope.launch { - val itemsResult = queryItems(cm, server, query, limit = 6) - if (itemsResult.error != null) { - Log.e("HVM.getItems", "$itemsResult") - } else { - val response = unmarshallItemResponse(itemsResult.stream!!) - mutableItems.value = (response as ItemsSuccess).items - } - } - } - - private suspend fun unmarshallItemResponse(stream: InputStream): ItemsResponse { - return withContext(Dispatchers.IO) { - ItemsResponse.unmarshal(stream) - } - } - - inner class SearchBarWatcher( - private val context: Context, - private val cm: ConnectivityManager - ) : - TextWatcher { - private val handler = Handler(Looper.getMainLooper()) - private var workRunnable = Runnable {} - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { - } - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - } - - override fun afterTextChanged(s: Editable?) { - handler.removeCallbacks(workRunnable) - workRunnable = Runnable { - val text = s.toString() - getItems( - cm, - Server.get(context), text - ) - } - handler.postDelayed( - workRunnable, - 750 - ) // todo(ux,low) make good time (probably between 500, 1000ms) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt deleted file mode 100644 index 65286da00108c3fe6d9957042c063c67bbb490a3..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt +++ /dev/null @@ -1,238 +0,0 @@ -package ml.adamsprogs.bimba.dashboard.ui.map - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Context.MODE_PRIVATE -import android.content.SharedPreferences -import android.content.res.Configuration.* -import android.graphics.Bitmap -import android.net.ConnectivityManager -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.content.edit -import androidx.core.graphics.drawable.toBitmap -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider -import com.google.android.material.snackbar.Snackbar -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.api.Position -import ml.adamsprogs.bimba.api.Server -import ml.adamsprogs.bimba.dashboard.MainActivity -import ml.adamsprogs.bimba.databinding.FragmentMapBinding -import ml.adamsprogs.bimba.dpToPixelI -import org.osmdroid.config.Configuration -import org.osmdroid.events.MapListener -import org.osmdroid.events.ScrollEvent -import org.osmdroid.events.ZoomEvent -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.CustomZoomButtonsController -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.TilesOverlay -import org.osmdroid.views.overlay.gestures.RotationGestureOverlay -import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider -import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay - -// todo[3.1] empty state on no network - -class MapFragment : Fragment() { - - private var maybeBinding: FragmentMapBinding? = null - private val binding get() = maybeBinding!! - - private lateinit var locationOverlay: MyLocationNewOverlay - private lateinit var mapViewModel: MapViewModel - - private val handler = Handler(Looper.getMainLooper()) - private var workRunnable = Runnable {} - - private var snack: Snackbar? = null - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mapViewModel = - ViewModelProvider(this)[MapViewModel::class.java] - - observeLocatables() - - maybeBinding = FragmentMapBinding.inflate(inflater, container, false) - val root: View = binding.root - - binding.map.setTileSource(TileSourceFactory.MAPNIK) - if (((context?.resources?.configuration?.uiMode ?: UI_MODE_NIGHT_UNDEFINED) - and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES - ) { - binding.map.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS) - } - binding.map.zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) - binding.map.setMultiTouchControls(true) - binding.map.overlays.add(RotationGestureOverlay(binding.map).apply { isEnabled = true }) - - locationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map) - context?.let { - centreMap(it.getSharedPreferences("shp", MODE_PRIVATE)) - - locationOverlay.setDirectionIcon( - AppCompatResources.getDrawable(it, R.drawable.navigation_arrow)?.mutate() - ?.toBitmap(dpToPixelI(36f), dpToPixelI(36f), Bitmap.Config.ARGB_8888) - ) - locationOverlay.setDirectionAnchor(.5f, .5f) - locationOverlay.setPersonIcon( - AppCompatResources.getDrawable(it, R.drawable.navigation_circle)?.mutate() - ?.toBitmap(dpToPixelI(24f), dpToPixelI(24f), Bitmap.Config.ARGB_8888) - ) - locationOverlay.setPersonAnchor(.5f, .5f) - } - - binding.floatingActionButton.setOnClickListener { - (context as MainActivity).onGpsClicked(this) - } - - binding.map.addMapListener(object : MapListener { - override fun onScroll(event: ScrollEvent?): Boolean { - return onMapMove() - } - - override fun onZoom(event: ZoomEvent?): Boolean { - return onMapMove() - } - }) - - binding.map.setOnTouchListener { _, _ -> - binding.floatingActionButton.show() - false - } - - return root - } - - private fun onMapMove(): Boolean { - snack?.dismiss() - return delayGetLocatables() - } - - private fun delayGetLocatables(delay: Long = 1000): Boolean { - handler.removeCallbacks(workRunnable) - workRunnable = Runnable { - getLocatables() - } - handler.postDelayed(workRunnable, delay) - return true - } - - private fun observeLocatables() { - mapViewModel.items.observe(viewLifecycleOwner) { - binding.map.overlays.removeAll { marker -> - marker is Marker - } - - it.forEach { locatable -> - val marker = Marker(binding.map) - marker.position = GeoPoint(locatable.location().latitude, locatable.location().longitude) - marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - marker.icon = context?.let { ctx -> locatable.icon(ctx, 2f) } - - context?.let { ctx -> - marker.setOnMarkerClickListener { _, _ -> - MapBottomSheet(locatable).apply { - (ctx as MainActivity?)?.supportFragmentManager?.let { fm -> - show(fm, MapBottomSheet.TAG) - } - } - true - } - } - binding.map.overlays.add(marker) - } - - binding.map.invalidate() - } - } - - fun showLocation() { - snack = - Snackbar.make(binding.root, getString(R.string.waiting_position), Snackbar.LENGTH_INDEFINITE) - snack!!.show() - binding.floatingActionButton.hide() - binding.map.overlays.removeAll { - it is MyLocationNewOverlay - } - locationOverlay.enableFollowLocation() - binding.map.overlays.add(locationOverlay) - locationOverlay.runOnFirstFix { - snack?.dismiss() - } - } - - private fun getLocatables() { - maybeBinding?.let { binding -> - val (bl, tr) = binding.map.boundingBox.let { - Pair( - Position(it.latSouth, it.lonWest), - Position(it.latNorth, it.lonEast) - ) - } - context?.let { - val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - mapViewModel.getLocatablesIn( - cm, - Server.get(it), bl, tr - ) - } - delayGetLocatables(30000) - } - } - - private fun centreMap(preferences: SharedPreferences) { - maybeBinding?.map?.controller?.apply { - setZoom(preferences.getFloat("mapZoom", 17.0f).toDouble()) - val startPoint = GeoPoint( - preferences.getFloat("mapCentreLat", 52.39511f).toDouble(), - preferences.getFloat("mapCentreLon", 16.89506f).toDouble() - ) - setCenter(startPoint) - } - } - - override fun onResume() { - super.onResume() - binding.map.onResume() - locationOverlay.enableMyLocation() - context?.let { ctx -> - ctx.getSharedPreferences("shp", MODE_PRIVATE).let { - Configuration.getInstance() - .load(ctx, it) - centreMap(it) - } - } - } - - override fun onPause() { - super.onPause() - binding.map.onPause() - locationOverlay.disableMyLocation() - val centre = binding.map.mapCenter - context?.let { ctx -> - ctx.getSharedPreferences("shp", MODE_PRIVATE).edit(true) { - this.putFloat("mapCentreLat", centre.latitude.toFloat()) - this.putFloat("mapCentreLon", centre.longitude.toFloat()) - this.putFloat("mapZoom", binding.map.zoomLevelDouble.toFloat()) - } - } - handler.removeCallbacks(workRunnable) - } - - override fun onDestroyView() { - super.onDestroyView() - maybeBinding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt deleted file mode 100644 index b9bd6d54ba976f4237bee03c105784a463c44199..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt +++ /dev/null @@ -1,168 +0,0 @@ -package ml.adamsprogs.bimba.dashboard.ui.map - -import android.content.ActivityNotFoundException -import android.content.Intent -import android.net.ConnectivityManager -import android.net.Uri -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.ImageView -import android.widget.TextView -import android.widget.Toast -import androidx.constraintlayout.widget.Group -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.api.* -import ml.adamsprogs.bimba.departures.DeparturesActivity -import java.io.InputStream - -class MapViewModel : ViewModel() { - - private val _items = MutableLiveData<List<Locatable>>() - val items: MutableLiveData<List<Locatable>> = _items - - fun getLocatablesIn(cm: ConnectivityManager, server: Server, bl: Position, tr: Position) { - viewModelScope.launch { - val locatablesResult = ml.adamsprogs.bimba.api.getLocatablesIn(cm, server, bl, tr) - if (locatablesResult.error != null) { - Log.e("Results.location", "$locatablesResult") - //todo showError(itemsResult.error) - } else { - val response = unmarshallLocatablesResponse(locatablesResult.stream!!) - _items.value = (response as LocatablesSuccess).locatables - } - } - } - - private suspend fun unmarshallLocatablesResponse(stream: InputStream): LocatablesResponse { - return withContext(Dispatchers.IO) { - LocatablesResponse.unmarshal(stream) - } - } -} - -class MapBottomSheet(private val locatable: Locatable) : BottomSheetDialogFragment() { - companion object { - const val TAG = "MapBottomSheet" - } - - private fun showVehicle(content: View, vehicle: Vehicle) { - content.findViewById<Group>(R.id.stop_group).visibility = View.GONE - content.findViewById<Group>(R.id.vehicle_group).visibility = View.VISIBLE - - context?.let { ctx -> - content.findViewById<TextView>(R.id.title).apply { - text = ctx.getString(R.string.vehicle_headsign, vehicle.Line.name, vehicle.Headsign) - contentDescription = ctx.getString( - R.string.vehicle_headsign_content_description, - vehicle.Line.name, - vehicle.Headsign - ) - } - // todo units -- [3.1] settings or system-based - content.findViewById<TextView>(R.id.speed_text).text = - ctx.getString(R.string.speed_in_km_per_h, vehicle.Speed * 3.6) - content.findViewById<TextView>(R.id.congestion_text).text = vehicle.congestion(ctx) - content.findViewById<TextView>(R.id.occupancy_text).text = vehicle.occupancy(ctx) - content.findViewById<ImageView>(R.id.ac).visibility = - if (vehicle.getCapability(Vehicle.Capability.AC)) { - View.VISIBLE - } else { - View.GONE - } - content.findViewById<ImageView>(R.id.bike).visibility = - if (vehicle.getCapability(Vehicle.Capability.BIKE)) { - View.VISIBLE - } else { - View.GONE - } - content.findViewById<ImageView>(R.id.voice).visibility = - if (vehicle.getCapability(Vehicle.Capability.VOICE)) { - View.VISIBLE - } else { - View.GONE - } - content.findViewById<ImageView>(R.id.ticket).visibility = - if (vehicle.let { - it.getCapability(Vehicle.Capability.TICKET_DRIVER) || it.getCapability(Vehicle.Capability.TICKET_MACHINE) - }) { - View.VISIBLE - } else { - View.GONE - } - content.findViewById<ImageView>(R.id.usb).visibility = - if (vehicle.getCapability(Vehicle.Capability.USB_CHARGING)) { - View.VISIBLE - } else { - View.GONE - } - } - } - - private fun showStop(content: View, stop: Stop) { - context?.let { ctx -> - content.findViewById<Group>(R.id.stop_group).visibility = View.VISIBLE - content.findViewById<Group>(R.id.vehicle_group).visibility = View.GONE - content.findViewById<TextView>(R.id.title).text = - context?.getString(R.string.stop_title, stop.name, stop.code) - content.findViewById<Button>(R.id.departures_button).setOnClickListener { - val intent = Intent(ctx, DeparturesActivity::class.java).apply { - putExtra("code", stop.code) - putExtra("name", stop.name) - } - startActivity(intent) - } - content.findViewById<Button>(R.id.navigation_button).setOnClickListener { - try { - startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse("geo:${stop.location().latitude},${stop.location().longitude}") - ) - ) - } catch (_: ActivityNotFoundException) { - Toast.makeText(context, ctx.getString(R.string.no_map_app), Toast.LENGTH_SHORT).show() - } - } - - stop.changeOptions(ctx).let { changeOptions -> - content.findViewById<TextView>(R.id.change_options).apply { - text = changeOptions.first - contentDescription = changeOptions.second - } - } - } - } - - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val content = inflater.inflate(R.layout.map_bottom_sheet, container, false) - content.apply { - when (locatable) { - is Vehicle -> { - showVehicle(this, locatable) - } - is Stop -> { - showStop(this, locatable) - } - } - } - //(dialog as BottomSheetDialog).behavior.peekHeight = dpToPixelI(90f) - - return content - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/voyage/VoyageFragment.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/voyage/VoyageFragment.kt deleted file mode 100644 index 0a610b07b30f546c98f712b8aea6584b9e06a094..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/voyage/VoyageFragment.kt +++ /dev/null @@ -1,42 +0,0 @@ -package ml.adamsprogs.bimba.dashboard.ui.voyage - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider -import ml.adamsprogs.bimba.databinding.FragmentVoyageBinding - -class VoyageFragment : Fragment() { - - private var _binding: FragmentVoyageBinding? = null - - // This property is only valid between onCreateView and - // onDestroyView. - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val voyageViewModel = - ViewModelProvider(this)[VoyageViewModel::class.java] - - _binding = FragmentVoyageBinding.inflate(inflater, container, false) - val root: View = binding.root - - val textView: TextView = binding.textDashboard - voyageViewModel.text.observe(viewLifecycleOwner) { - textView.text = it - } - return root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/voyage/VoyageViewModel.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/voyage/VoyageViewModel.kt deleted file mode 100644 index 0dd58b3469156839e197127f7dd1e7846d408da9..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/voyage/VoyageViewModel.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ml.adamsprogs.bimba.dashboard.ui.voyage - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel - -class VoyageViewModel : ViewModel() { - - private val _text = MutableLiveData<String>().apply { - value = "This is voyage Fragment" - } - val text: LiveData<String> = _text -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/departures/Departures.kt b/app/src/main/java/ml/adamsprogs/bimba/departures/Departures.kt deleted file mode 100644 index bf522b8731c94c5357101e8d8d82064aee2a6c1a..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/departures/Departures.kt +++ /dev/null @@ -1,299 +0,0 @@ -package ml.adamsprogs.bimba.departures - -import android.annotation.SuppressLint -import android.content.Context -import android.content.DialogInterface -import android.content.res.Configuration.* -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.api.Departure -import ml.adamsprogs.bimba.api.Vehicle -import ml.adamsprogs.bimba.dpToPixelI -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.CustomZoomButtonsController -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.TilesOverlay -import org.osmdroid.views.overlay.gestures.RotationGestureOverlay -import java.util.* -import kotlin.collections.HashMap - - -class BimbaDepartureViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val root: View = itemView.findViewById(R.id.departure) - val lineIcon: ImageView = itemView.findViewById(R.id.line_icon) - val departureTime: TextView = itemView.findViewById(R.id.departure_time) - val lineName: TextView = itemView.findViewById(R.id.departure_line) - val headsign: TextView = itemView.findViewById(R.id.departure_headsign) - - companion object { - fun bind( - departure: Departure, - holder: BimbaDepartureViewHolder?, - context: Context?, - onClickListener: (Departure) -> Unit - ) { - holder?.root?.setOnClickListener { - onClickListener(departure) - } - holder?.lineIcon?.setImageBitmap(departure.line.icon(context!!)) - holder?.lineIcon?.contentDescription = departure.line.type.name - holder?.lineName?.text = departure.line.name - holder?.headsign?.text = context?.getString(R.string.departure_headsign, departure.headsign) - holder?.headsign?.contentDescription = - context?.getString(R.string.departure_headsign_content_description, departure.headsign) - - holder?.departureTime?.text = departure.statusText(context) - } - } -} - -class BimbaDeparturesAdapter( - private val inflater: LayoutInflater, - private val context: Context?, - private var departures: List<Departure>, - private val onClickListener: ((Departure) -> Unit) -) : - RecyclerView.Adapter<BimbaDepartureViewHolder>() { - - private var departuresPositions: MutableMap<String, Int> = HashMap() - - init { - departures = - departures.map { // fixme (!!) does szczanieckiej not populate departure.vehicle.(line,headsign)? - Departure( - it.ID, it.line, it.headsign, it.time, it.status, it.isRealtime, - it.stopOrder, Vehicle( - it.vehicle.ID, - it.vehicle.Position, - it.vehicle.Capabilities, - it.vehicle.Speed, - it.line, - it.headsign, - it.vehicle.CongestionLevel, - it.vehicle.OccupancyStatus - ), - it.boarding - ) - } - departures.forEachIndexed { i, departure -> - departuresPositions[departure.ID] = i - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaDepartureViewHolder { - val rowView = inflater.inflate(R.layout.departure, parent, false) - return BimbaDepartureViewHolder(rowView) - } - - override fun onBindViewHolder(holder: BimbaDepartureViewHolder, position: Int) { - BimbaDepartureViewHolder.bind(departures[position], holder, context, onClickListener) - } - - override fun getItemCount(): Int = departures.size - - fun get(ID: String): Departure? { - val position = departuresPositions[ID] - return if (position == null) { - null - } else { - departures[position] - } - } - - @SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil - fun update(items: List<Departure>) { - val newPositions: MutableMap<String, Int> = HashMap() - items.forEachIndexed { i, departure -> - newPositions[departure.ID] = i - } - - departures = items.map { // fixme (!!) - Departure( - it.ID, it.line, it.headsign, it.time, it.status, it.isRealtime, - it.stopOrder, Vehicle( - it.vehicle.ID, - it.vehicle.Position, - it.vehicle.Capabilities, - it.vehicle.Speed, - it.line, - it.headsign, - it.vehicle.CongestionLevel, - it.vehicle.OccupancyStatus - ), - it.boarding - ) - } - departuresPositions = newPositions - notifyDataSetChanged() - } -} - -class DepartureBottomSheet(private var departure: Departure) : BottomSheetDialogFragment() { - companion object { - const val TAG = "DepartureBottomSheet" - } - - private var cancelCallback: (() -> Unit)? = null - - fun setOnCancel(callback: () -> Unit) { - cancelCallback = callback - } - - override fun onCancel(dialog: DialogInterface) { - super.onCancel(dialog) - cancelCallback?.let { it() } - } - - fun departureID(): String { - return departure.ID - } - - fun update(departure: Departure) { - this.departure = departure - this.view?.let { context?.let { ctx -> setContent(it, ctx) } } - } - - private fun setContent(view: View, ctx: Context) { - view.apply { - findViewById<TextView>(R.id.time).text = departure.timeString(ctx) - - findViewById<ImageView>(R.id.rt_icon).apply { - visibility = if (departure.isRealtime) { - View.VISIBLE - } else { - View.GONE - } - } - findViewById<ImageView>(R.id.wheelchair_icon).apply { - visibility = if (departure.vehicle.let { - it.getCapability(Vehicle.Capability.LOW_FLOOR) || it.getCapability(Vehicle.Capability.LOW_ENTRY) || it.getCapability( - Vehicle.Capability.RAMP - ) - }) { - View.VISIBLE - } else { - View.GONE - } - } - - findViewById<TextView>(R.id.line).apply { - contentDescription = getString( - R.string.vehicle_headsign_content_description, - departure.line.name, - departure.headsign - ) - text = getString(R.string.vehicle_headsign, departure.line.name, departure.headsign) - } - - findViewById<TextView>(R.id.boarding_text).text = departure.boardingText(ctx) - // todo units -- [3.1] settings or system-based - findViewById<TextView>(R.id.speed_text).text = - getString(R.string.speed_in_km_per_h, departure.vehicle.Speed * 3.6) - findViewById<TextView>(R.id.congestion_text).text = departure.vehicle.congestion(ctx) - findViewById<TextView>(R.id.occupancy_text).text = departure.vehicle.occupancy(ctx) - - findViewById<ImageView>(R.id.ac).visibility = - if (departure.vehicle.getCapability(Vehicle.Capability.AC)) { - View.VISIBLE - } else { - View.GONE - } - findViewById<ImageView>(R.id.bike).visibility = - if (departure.vehicle.getCapability(Vehicle.Capability.BIKE)) { - View.VISIBLE - } else { - View.GONE - } - findViewById<ImageView>(R.id.voice).visibility = - if (departure.vehicle.getCapability(Vehicle.Capability.VOICE)) { - View.VISIBLE - } else { - View.GONE - } - findViewById<ImageView>(R.id.ticket).visibility = - if (departure.vehicle.let { - it.getCapability(Vehicle.Capability.TICKET_DRIVER) || it.getCapability(Vehicle.Capability.TICKET_MACHINE) - }) { - View.VISIBLE - } else { - View.GONE - } - findViewById<ImageView>(R.id.usb).visibility = - if (departure.vehicle.getCapability(Vehicle.Capability.USB_CHARGING)) { - View.VISIBLE - } else { - View.GONE - } - findViewById<MapView>(R.id.map).let { map -> - if (departure.vehicle.Position.isZero()) { - map.visibility = View.GONE - return@let - } - map.controller.apply { // todo[3.1] glide to centre, not jump - setZoom(19.0f.toDouble()) - setCenter( - GeoPoint( - departure.vehicle.location().latitude, - departure.vehicle.location().longitude - ) - ) - } - - map.overlays.removeAll { marker -> - marker is Marker - } - val marker = Marker(map).apply { - position = - GeoPoint(departure.vehicle.location().latitude, departure.vehicle.location().longitude) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - icon = context?.let { ctx -> departure.vehicle.icon(ctx, 2f) } - setOnClickListener {} - } - map.overlays.add(marker) - map.invalidate() - } - } - } - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val content = inflater.inflate(R.layout.departure_bottom_sheet, container, false) - - context?.let { ctx -> - content.apply { - findViewById<MapView>(R.id.map).let { map -> - map.setTileSource(TileSourceFactory.MAPNIK) - if (((context?.resources?.configuration?.uiMode ?: UI_MODE_NIGHT_UNDEFINED) - and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES - ) { - map.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS) - } - map.zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) - map.setOnTouchListener { _, _ -> true } - map.setMultiTouchControls(true) - map.overlays.add(RotationGestureOverlay(map).apply { isEnabled = true }) - } - - setContent(this, ctx) - - (dialog as BottomSheetDialog).behavior.peekHeight = dpToPixelI(180f) - - } - } - return content - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt deleted file mode 100644 index a0ceb41bd72a35a1067be187cf3c925cc30a7727..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt +++ /dev/null @@ -1,153 +0,0 @@ -package ml.adamsprogs.bimba.departures - -import android.content.Context -import android.content.Intent -import android.net.ConnectivityManager -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.content.res.ResourcesCompat -import androidx.core.view.WindowCompat -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.coroutines.* -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.api.* -import ml.adamsprogs.bimba.databinding.ActivityDeparturesBinding -import java.io.InputStream - -class DeparturesActivity : AppCompatActivity() { - private var _binding: ActivityDeparturesBinding? = null - private val binding get() = _binding!! - - private lateinit var adapter: BimbaDeparturesAdapter - - private val handler = Handler(Looper.getMainLooper()) - private var runnable = Runnable {} - - private var openBottomSheet: DepartureBottomSheet? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - _binding = ActivityDeparturesBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.collapsingLayout.apply { - title = getName() - val tf = ResourcesCompat.getFont(this@DeparturesActivity, R.font.yellowcircle8) - setCollapsedTitleTypeface(tf) - setExpandedTitleTypeface(tf) - } - - binding.departuresRecycler.layoutManager = LinearLayoutManager(this) - adapter = BimbaDeparturesAdapter(layoutInflater, this, listOf()) { - DepartureBottomSheet(it).apply { - show(supportFragmentManager, DepartureBottomSheet.TAG) - openBottomSheet = this - setOnCancel { openBottomSheet = null } - } - } - binding.departuresRecycler.adapter = adapter - WindowCompat.setDecorFitsSystemWindows(window, false) - } - - override fun onResume() { - super.onResume() - getDepartures() - } - - override fun onPause() { - super.onPause() - handler.removeCallbacks(runnable) - } - - private fun getName(): String { - return when (intent?.action) { - Intent.ACTION_VIEW -> getCode() - null -> intent?.extras?.getString("name") ?: "" - else -> "" - } - } - - private fun getCode(): String { - @Suppress("SpellCheckingInspection") - return when (intent?.action) { - Intent.ACTION_VIEW -> intent?.data?.getQueryParameter("przystanek") ?: "" - null -> intent?.extras?.getString("code") ?: "" - else -> "" - } - } - - private fun getDepartures() { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - MainScope().launch { - val departuresResult = getDepartures( - cm, - Server.get(this@DeparturesActivity), getCode() - ) - val response = if (departuresResult.stream != null) { - unmarshallDepartureResponse(departuresResult.stream) - } else { - null - } - if (departuresResult.error != null) { - Log.e("Departures", "$departuresResult") - Log.e("Departures", "$response") - showError(departuresResult.error) - } else { - openBottomSheet?.departureID()?.let { adapter.get(it) }?.let { openBottomSheet?.update(it) } - updateItems((response as DeparturesSuccess)) - } - } - handler.removeCallbacks(runnable) - runnable = Runnable { getDepartures() } - handler.postDelayed(runnable, 30 * 1000) - } - - private suspend fun unmarshallDepartureResponse(stream: InputStream): DeparturesResponse { - return withContext(Dispatchers.IO) { - DeparturesResponse.unmarshal(stream) - } - } - - private fun showError(error: Error) { - binding.departuresProgress.visibility = View.GONE - binding.departuresRecycler.visibility = View.GONE - binding.errorImage.visibility = View.VISIBLE - binding.errorText.visibility = View.VISIBLE - - binding.errorText.text = getString(error.stringResource) - binding.errorImage.setImageDrawable(AppCompatResources.getDrawable(this, error.imageResource)) - } - - private fun updateItems(response: DeparturesSuccess) { - binding.departuresProgress.visibility = View.GONE - adapter.update(response.departures) - binding.collapsingLayout.apply { - title = response.stop.name - } - if (response.departures.isEmpty()) { - binding.errorImage.visibility = View.VISIBLE - binding.errorText.visibility = View.VISIBLE - binding.departuresRecycler.visibility = View.GONE - - binding.errorText.text = getString(R.string.no_departures) - binding.errorImage.setImageDrawable( - AppCompatResources.getDrawable( - this, - R.drawable.error_search - ) - ) - } else { - binding.departuresOverlay.visibility = View.GONE - binding.errorImage.visibility = View.GONE - binding.errorText.visibility = View.GONE - binding.departuresRecycler.visibility = View.VISIBLE - } - // todo [3.1] alerts - // todo [3.1] stop info - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/onboarding/FirstRunActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/onboarding/FirstRunActivity.kt deleted file mode 100644 index b289c3268b2254d98093af0186d65d5613160f80..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/onboarding/FirstRunActivity.kt +++ /dev/null @@ -1,23 +0,0 @@ -package ml.adamsprogs.bimba.onboarding - -import android.content.Intent -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import ml.adamsprogs.bimba.dashboard.MainActivity - -class FirstRunActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() - super.onCreate(savedInstanceState) - - val preferences = getSharedPreferences("shp", MODE_PRIVATE) - val intent = if (preferences.getBoolean("firstRun", true)) { - Intent(this, OnboardingActivity::class.java) - } else { - Intent(this, MainActivity::class.java) - } - startActivity(intent) - finish() - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/onboarding/OnboardingActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/onboarding/OnboardingActivity.kt deleted file mode 100644 index f49ba2470f879f08cb2bdea516719d1aa3f83565..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/onboarding/OnboardingActivity.kt +++ /dev/null @@ -1,73 +0,0 @@ -package ml.adamsprogs.bimba.onboarding - -import android.content.Intent -import android.graphics.Typeface -import android.os.Bundle -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.style.RelativeSizeSpan -import android.text.style.StyleSpan -import android.widget.Button -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.databinding.ActivityOnboardingBinding -import ml.adamsprogs.bimba.settings.ServerChooserActivity - -class OnboardingActivity : AppCompatActivity() { - private var _binding: ActivityOnboardingBinding? = null - private val binding get() = _binding!! - - private val activityLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (!getSharedPreferences("shp", MODE_PRIVATE).getBoolean("firstRun", true)) { - finish() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - _binding = ActivityOnboardingBinding.inflate(layoutInflater) - setContentView(binding.root) - - - prepareButton( - binding.buttonSimple, - getString(R.string.onboarding_simple), - getString(R.string.onboarding_simple_action), - true - ) - prepareButton( - binding.buttonAdvanced, - getString(R.string.onboarding_advanced), - getString(R.string.onboarding_advanced_action), - false - ) - } - - private fun prepareButton(button: Button, title: String, description: String, simple: Boolean) { - button.text = SpannableStringBuilder().apply { - append( - title, - StyleSpan(Typeface.BOLD), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - append("\n") - append( - description, - RelativeSizeSpan(.75f), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - button.setOnClickListener { - moveOn(simple) - } - } - - private fun moveOn(simple: Boolean) { - val intent = Intent(this, ServerChooserActivity::class.java).apply { - putExtra("simple", simple) - } - activityLauncher.launch(intent) - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt b/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt deleted file mode 100644 index 4ed01d22ce933369cfabbe52e0cb38c63df37c62..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt +++ /dev/null @@ -1,134 +0,0 @@ -package ml.adamsprogs.bimba.search - -import android.annotation.SuppressLint -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.mancj.materialsearchbar.adapter.SuggestionsAdapter -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.api.Item -import ml.adamsprogs.bimba.api.Line -import ml.adamsprogs.bimba.api.Stop - -class BimbaViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val root: View = itemView.findViewById(R.id.suggestion) - val icon: ImageView = itemView.findViewById(R.id.suggestion_image) - val title: TextView = itemView.findViewById(R.id.suggestion_title) - val description: TextView = itemView.findViewById(R.id.suggestion_description) - - companion object { - fun bind( - item: Item, - holder: BimbaViewHolder?, - context: Context?, - onClickListener: (Item) -> Unit - ) { - when (item) { - is Stop -> bindStop(item, holder, context) - is Line -> bindLine(item, holder, context) - else -> throw NotImplementedError("Shouldn't happen") - } - holder?.root?.setOnClickListener { - onClickListener(item) - } - } - - private fun bindStop(stop: Stop, holder: BimbaViewHolder?, context: Context?) { - holder?.icon?.apply { - setImageDrawable(stop.icon(context!!)) - contentDescription = context.getString(R.string.stop_content_description) - } - holder?.title?.text = context?.getString(R.string.stop_title, stop.name, stop.code) - context?.let { - stop.changeOptions(it).let { changeOptions -> - holder?.description?.apply { - text = changeOptions.first - contentDescription = changeOptions.second - } - } - } - } - - private fun bindLine(line: Line, holder: BimbaViewHolder?, context: Context?) { - holder?.icon?.apply { - setImageBitmap(line.icon(context!!)) - contentDescription = line.type.name - colorFilter = null - } - holder?.title?.text = line.name - holder?.description?.text = context?.getString( - R.string.line_headsigns, - line.headsignsThere.joinToString { it }, - line.headsignsBack.joinToString { it }) - holder?.description?.contentDescription = context?.getString( - R.string.line_headsigns_content_description, - line.headsignsThere.joinToString { it }, - line.headsignsBack.joinToString { it }) - } - } -} - -interface Adapter { - fun createViewHolder( - inflater: LayoutInflater, - layout: Int, - parent: ViewGroup - ): BimbaViewHolder { - val rowView = inflater.inflate(layout, parent, false) - return BimbaViewHolder(rowView) - } - - fun bindSuggestionHolder( - item: Item, - holder: BimbaViewHolder?, - context: Context?, - onClickListener: (Item) -> Unit - ) { - BimbaViewHolder.bind(item, holder, context, onClickListener) - } -} - -class BimbaResultsAdapter( - private val inflater: LayoutInflater, - private val context: Context?, - private var items: List<Item>, - private val onClickListener: ((Item) -> Unit) -) : - RecyclerView.Adapter<BimbaViewHolder>(), Adapter { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaViewHolder { - return createViewHolder(inflater, R.layout.result, parent) - } - - override fun onBindViewHolder(holder: BimbaViewHolder, position: Int) { - bindSuggestionHolder(items[position], holder, context, onClickListener) - } - - override fun getItemCount(): Int = items.size - - @SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil - fun update(items: List<Item>) { - this.items = items - notifyDataSetChanged() - } -} - -class BimbaSuggestionsAdapter( - inflater: LayoutInflater, - private val context: Context?, - private val onClickListener: ((Item) -> Unit) -) : - SuggestionsAdapter<Item, BimbaViewHolder>(inflater), Adapter { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaViewHolder { - return createViewHolder(layoutInflater, R.layout.suggestion, parent) - } - - override fun getSingleViewHeight(): Int = 72 - - override fun onBindSuggestionHolder(item: Item, holder: BimbaViewHolder?, pos: Int) { - bindSuggestionHolder(item, holder, context, onClickListener) - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt deleted file mode 100644 index e15ba288a6f5e5efc51f1602770bd5c6cf7d4084..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt +++ /dev/null @@ -1,188 +0,0 @@ -package ml.adamsprogs.bimba.search - -import android.content.Context -import android.content.Intent -import android.location.Location -import android.location.LocationListener -import android.location.LocationManager -import android.net.ConnectivityManager -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.view.WindowCompat -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.coroutines.* -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.api.* -import ml.adamsprogs.bimba.databinding.ActivityResultsBinding -import ml.adamsprogs.bimba.departures.DeparturesActivity -import java.io.InputStream - -class ResultsActivity : AppCompatActivity(), LocationListener { - enum class Mode { - MODE_LOCATION, MODE_SEARCH - } - - private var _binding: ActivityResultsBinding? = null - private val binding get() = _binding!! - - private lateinit var adapter: BimbaResultsAdapter - - private val handler = Handler(Looper.getMainLooper()) - private var runnable = Runnable {} - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - _binding = ActivityResultsBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.resultsRecycler.layoutManager = LinearLayoutManager(this) - adapter = BimbaResultsAdapter(layoutInflater, this, listOf()) { - when (it) { - is Stop -> { - val intent = Intent(this, DeparturesActivity::class.java).apply { - putExtra("code", it.code) - putExtra("name", it.name) - } - startActivity(intent) - } - is Line -> { - TODO("[3.1] start line graph activity") - } - } - } - binding.resultsRecycler.adapter = adapter - setSupportActionBar(binding.topAppBar) - - WindowCompat.setDecorFitsSystemWindows(window, false) - - @Suppress("DEPRECATION") // fixme later getSerializable in API>=33 - when (intent.extras?.get("mode")) { - Mode.MODE_LOCATION -> { - supportActionBar?.title = getString(R.string.stops_nearby) - locate() - } - Mode.MODE_SEARCH -> { - val query = intent.extras?.getString("query")!! - supportActionBar?.title = getString(R.string.results_for, query) - getItemsByQuery(Server.get(this), query) - } - } - } - - private fun locate() { - try { - val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager - locationManager.requestLocationUpdates( - LocationManager.GPS_PROVIDER, 1000 * 60 * 10, 100f, this - ) - handler.removeCallbacks(runnable) - runnable = Runnable { - showError(Error(0, R.string.error_gps, R.drawable.error_gps)) - } - handler.postDelayed(runnable, 60 * 1000) - locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - ?.let { onLocationChanged(it) } - } catch (_: SecurityException) { - // this won’t happen because we don’t start this activity without location permission - } - } - - override fun onLocationChanged(location: Location) { - handler.removeCallbacks(runnable) - getItemsByLocation(Server.get(this), Position(location.latitude, location.longitude)) - } - - override fun onResume() { - super.onResume() - @Suppress("DEPRECATION") // fixme later getSerializable in API>=33 - if (intent.extras?.get("mode") == Mode.MODE_LOCATION) { - locate() - } - } - - override fun onPause() { - super.onPause() - val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager - locationManager.removeUpdates(this) - handler.removeCallbacks(runnable) - } - - override fun onDestroy() { - super.onDestroy() - val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager - locationManager.removeUpdates(this) - handler.removeCallbacks(runnable) - } - - private fun getItemsByQuery(server: Server, query: String) { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - MainScope().launch { - val itemsResult = queryItems(cm, server, query) - if (itemsResult.error != null) { - Log.e("Results.query", "$itemsResult") - showError(itemsResult.error) - } else { - val response = unmarshallItemResponse(itemsResult.stream!!) - updateItems((response as ItemsSuccess).items) - } - } - } - - private fun getItemsByLocation(server: Server, position: Position) { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - MainScope().launch { - val itemsResult = locateItems(cm, server, position) - if (itemsResult.error != null) { - Log.e("Results.location", "$itemsResult") - showError(itemsResult.error) - } else { - val response = unmarshallItemResponse(itemsResult.stream!!) - updateItems((response as ItemsSuccess).items) - } - } - } - - private fun showError(error: Error) { - binding.resultsProgress.visibility = View.GONE - binding.resultsRecycler.visibility = View.GONE - binding.errorImage.visibility = View.VISIBLE - binding.errorText.visibility = View.VISIBLE - - binding.errorText.text = getString(error.stringResource) - binding.errorImage.setImageDrawable(AppCompatResources.getDrawable(this, error.imageResource)) - } - - private fun updateItems(items: List<Item>) { - binding.resultsProgress.visibility = View.GONE - adapter.update(items) - if (items.isEmpty()) { - binding.errorImage.visibility = View.VISIBLE - binding.errorText.visibility = View.VISIBLE - binding.resultsRecycler.visibility = View.GONE - - binding.errorText.text = getString(R.string.error_404) - binding.errorImage.setImageDrawable( - AppCompatResources.getDrawable( - this, - R.drawable.error_search - ) - ) - } else { - binding.resultsOverlay.visibility = View.GONE - binding.errorImage.visibility = View.GONE - binding.errorText.visibility = View.GONE - binding.resultsRecycler.visibility = View.VISIBLE - } - } - - private suspend fun unmarshallItemResponse(stream: InputStream): ItemsResponse { - return withContext(Dispatchers.IO) { - ItemsResponse.unmarshal(stream) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/settings/ServerChooserActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/settings/ServerChooserActivity.kt deleted file mode 100644 index d39f1d61c77c6318abaf8c6e439e011e74b8308d..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/settings/ServerChooserActivity.kt +++ /dev/null @@ -1,139 +0,0 @@ -package ml.adamsprogs.bimba.settings - -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.net.ConnectivityManager -import android.os.Bundle -import android.util.Log -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.content.edit -import androidx.core.widget.addTextChangedListener -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.api.FeedsResponse -import ml.adamsprogs.bimba.api.FeedsSuccess -import ml.adamsprogs.bimba.api.Server -import ml.adamsprogs.bimba.api.getFeeds -import ml.adamsprogs.bimba.databinding.ActivityServerChooserBinding -import ml.adamsprogs.bimba.settings.feeds.FeedChooserActivity - -class ServerChooserActivity : AppCompatActivity() { - private var _binding: ActivityServerChooserBinding? = null - private val binding get() = _binding!! - - private val activityLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (!preferences.getBoolean("inFeedsTransaction", true)) { - finish() - } - } - - private lateinit var preferences: SharedPreferences - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - preferences = getSharedPreferences("shp", MODE_PRIVATE) - - if (intent.getBooleanExtra("simple", false)) { - setServer("bimba.apiote.xyz", "") - runFeedsActivity() - finish() - } - - _binding = ActivityServerChooserBinding.inflate(layoutInflater) - setContentView(binding.root) - - preferences.edit(true) { - putBoolean("inFeedsTransaction", true) - } - - binding.button.isEnabled = false - binding.serverField.editText!!.addTextChangedListener { editable -> - binding.button.isEnabled = !editable.isNullOrBlank() - } - - if (!preferences.getBoolean("firstRun", true)) { - Server.get(this).let { server -> - binding.serverField.editText!!.setText(server.host) - binding.tokenField.editText!!.setText(server.token) - } - } - - binding.button.setOnClickListener { - setServer( - binding.serverField.editText!!.text.toString(), - binding.tokenField.editText!!.text.toString() - ) - checkServer() - } - } - - private fun showDialog( - title: Int, - description: Int, - icon: Int, - onPositive: (() -> Unit)? - ) { - MaterialAlertDialogBuilder(this) - .setIcon(AppCompatResources.getDrawable(this, icon)) - .setTitle(getString(title)) - .setMessage(getString(description)) - .setNegativeButton(resources.getString(R.string.cancel)) { _, _ -> }.apply { - if (onPositive != null) { - setPositiveButton(resources.getString(R.string.cont)) { _, _ -> - onPositive() - } - } - } - .show() - } - - private fun checkServer() { - // todo [api-freeze] check is really bimba (/.well-known/bimba) - MainScope().launch { - val result = getFeeds( - getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, - Server.get(this@ServerChooserActivity) - ) - if (result.error != null) { - Log.w("ServerChooser", "$result") - showDialog(R.string.error, result.error.stringResource, result.error.imageResource, null) - return@launch - } - - val response = FeedsResponse.unmarshal(result.stream!!) as FeedsSuccess - val token = preferences.getString("token", "") - if (response.private && token == "") { - showDialog(R.string.error, R.string.server_private_question, R.drawable.error_sec, null) - return@launch - } - if (response.rateLimited && token == "") { - showDialog( - R.string.rate_limit, - R.string.server_rate_limited_question, - R.drawable.error_limit - ) { - runFeedsActivity() - } - return@launch - } - runFeedsActivity() - } - } - - private fun setServer(hostname: String, token: String) { - preferences.edit(true) { - putString("host", hostname) - putString("token", token) - } - } - - private fun runFeedsActivity() { - activityLauncher.launch(Intent(this, FeedChooserActivity::class.java)) - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedChooserActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedChooserActivity.kt deleted file mode 100644 index f6b54709744b3b1f7457987b065ec7a8760b8a1b..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedChooserActivity.kt +++ /dev/null @@ -1,81 +0,0 @@ -package ml.adamsprogs.bimba.settings.feeds - -import android.content.Context -import android.content.Intent -import android.net.ConnectivityManager -import android.os.Bundle -import android.util.Log -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import ml.adamsprogs.bimba.api.FeedsResponse -import ml.adamsprogs.bimba.api.FeedsSuccess -import ml.adamsprogs.bimba.api.Server -import ml.adamsprogs.bimba.api.getFeeds -import ml.adamsprogs.bimba.dashboard.MainActivity -import ml.adamsprogs.bimba.databinding.ActivityFeedChooserBinding - -class FeedChooserActivity : AppCompatActivity() { - private var _binding: ActivityFeedChooserBinding? = null - private val binding get() = _binding!! - - private lateinit var adapter: BimbaFeedInfoAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - _binding = ActivityFeedChooserBinding.inflate(layoutInflater) - setContentView(binding.root) - - setUpRecycler() - getServer() - - binding.button.setOnClickListener { - moveOn() - } - } - - private fun setUpRecycler() { - binding.resultsRecycler.layoutManager = LinearLayoutManager(this) - adapter = BimbaFeedInfoAdapter(layoutInflater, listOf(), this) { - FeedBottomSheet(it).show(supportFragmentManager, FeedBottomSheet.TAG) - } - binding.resultsRecycler.adapter = adapter - } - - private fun getServer() { - binding.progress.visibility = View.VISIBLE - binding.resultsRecycler.visibility = View.GONE - - MainScope().launch { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val feedsResult = getFeeds(cm, Server.get(this@FeedChooserActivity)) - if (feedsResult.error != null) { - Log.w("FeedChooser", "$feedsResult") - return@launch - } - val response = FeedsResponse.unmarshal(feedsResult.stream!!) - updateItems(response as FeedsSuccess) - } - } - - private fun moveOn() { - val preferences = getSharedPreferences("shp", MODE_PRIVATE) - preferences.edit(true) { - putBoolean("inFeedsTransaction", false) - } - if (preferences.getBoolean("firstRun", true)) { - val intent = Intent(this, MainActivity::class.java) - startActivity(intent) - } - finish() - } - - private fun updateItems(response: FeedsSuccess) { - binding.progress.visibility = View.GONE - binding.resultsRecycler.visibility = View.VISIBLE - adapter.update(response.feeds) - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedInfos.kt b/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedInfos.kt deleted file mode 100644 index 380ad2a4060a5ab92be796789ebacc837ea14be1..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedInfos.kt +++ /dev/null @@ -1,107 +0,0 @@ -package ml.adamsprogs.bimba.settings.feeds - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Context.MODE_PRIVATE -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.core.content.edit -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.materialswitch.MaterialSwitch -import ml.adamsprogs.bimba.R -import ml.adamsprogs.bimba.api.FeedInfo - - -class BimbaFeedInfoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val root: View = itemView.findViewById(R.id.feed) - val switch: MaterialSwitch = itemView.findViewById(R.id.feed_switch) - val name: TextView = itemView.findViewById(R.id.feed_name) - - companion object { - fun bind( - feed: FeedInfo, - context: Context, - holder: BimbaFeedInfoViewHolder?, - onClickListener: (FeedInfo) -> Unit - ) { - val shp = context.getSharedPreferences("shp", MODE_PRIVATE) - val host = shp.getString("host", "bimba.apiote.xyz")!! - val enabledFeeds = - shp.getString("${host}_feeds", "")!!.split(",").associateWith { }.toMutableMap() - - holder?.root?.setOnClickListener { - onClickListener(feed) - } - holder?.name?.text = feed.name - holder?.switch?.apply { - isChecked = feed.id in enabledFeeds - setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - enabledFeeds[feed.id] = Unit - } else { - enabledFeeds.remove(feed.id) - } - shp.edit(true) { - putString( - "${host}_feeds", - enabledFeeds.map { it.key }.filter { it != "" }.joinToString(separator = ",") - ) - } - } - } - } - } -} - -class BimbaFeedInfoAdapter( - private val inflater: LayoutInflater, - private var feeds: List<FeedInfo>, - private val context: Context, - private val onClickListener: ((FeedInfo) -> Unit) -) : - RecyclerView.Adapter<BimbaFeedInfoViewHolder>() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaFeedInfoViewHolder { - val rowView = inflater.inflate(R.layout.feedinfo, parent, false) - return BimbaFeedInfoViewHolder(rowView) - } - - override fun onBindViewHolder(holder: BimbaFeedInfoViewHolder, position: Int) { - BimbaFeedInfoViewHolder.bind(feeds[position], context, holder, onClickListener) - } - - override fun getItemCount(): Int = feeds.size - - @SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil - fun update(items: List<FeedInfo>) { - feeds = items - notifyDataSetChanged() - } -} - -class FeedBottomSheet(private var feed: FeedInfo) : BottomSheetDialogFragment() { - companion object { - const val TAG = "DepartureBottomSheet" - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val content = inflater.inflate(R.layout.feed_bottom_sheet, container, false) - content.findViewById<TextView>(R.id.title).text = feed.name - content.findViewById<TextView>(R.id.description).text = feed.description - content.findViewById<TextView>(R.id.attribution).text = feed.attribution - content.findViewById<TextView>(R.id.update_time).text = - getString(R.string.last_update, if (context != null) { - feed.lastUpdate.toString(requireContext()) - } else { - feed.lastUpdate.let { "${it.year}-${it.month}-${it.day}" } - }) - return content - } -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/utils.kt b/app/src/main/java/ml/adamsprogs/bimba/utils.kt deleted file mode 100644 index 7b803dca60a6a7e64e72c03e4f4ac8f010ee3830..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/utils.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ml.adamsprogs.bimba - -import android.content.res.Resources -import android.util.DisplayMetrics -import kotlin.math.roundToInt - -fun dpToPixel(dp: Float): Float { - val metrics: DisplayMetrics = Resources.getSystem().displayMetrics - return dp * (metrics.densityDpi / 160f) -} - -fun dpToPixelI(dp: Float): Int = dpToPixel(dp).roundToInt() \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt b/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt new file mode 100644 index 0000000000000000000000000000000000000000..34a162856469ff0e7d78c1277a3e48375f4e35e4 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt @@ -0,0 +1,22 @@ +package xyz.apiote.bimba.czwek + +import org.osmdroid.config.Configuration +import java.io.File + +// todo [3.1] style + +class Bimba : android.app.Application() { + override fun onCreate() { + super.onCreate() + Configuration.getInstance() + .let { config -> + config.load( + applicationContext, + applicationContext.getSharedPreferences("shp", MODE_PRIVATE) + ) + config.osmdroidBasePath = File(applicationContext.cacheDir.absolutePath, "osmdroid") + + config.osmdroidTileCache = File(config.osmdroidBasePath.absolutePath, "tile") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt new file mode 100644 index 0000000000000000000000000000000000000000..b0ead8725723cdf1579ad1ff8cb9148ffeef32ab --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt @@ -0,0 +1,155 @@ +package xyz.apiote.bimba.czwek.api + +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.net.ConnectivityManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import xyz.apiote.bimba.czwek.R +import java.io.IOException +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.URL +import java.net.URLEncoder + +// todo [3.1] constants + +// todo [3.1] create Repository between models and api/fs +// todo [3.1] in Repository check if responses are BARE or HTML + +data class Server(val host: String, val token: String, val feeds: String, val apiPath: String) { + companion object { + fun get(context: Context): Server { + val preferences = context.getSharedPreferences("shp", MODE_PRIVATE) + val host = preferences.getString("host", "bimba.apiote.xyz")!! + return Server( + host, preferences.getString("token", "")!!, + preferences.getString("${host}_feeds", "")!!, + preferences.getString("apiPath", "")!!, + ) + } + } +} + +data class Result(val stream: InputStream?, val error: Error?) + +data class Error(val statusCode: Int, val stringResource: Int, val imageResource: Int) + +suspend fun getBimba(cm: ConnectivityManager, server: Server): Result { + return try { + rawRequest( + URL("${hostWithScheme(server.host)}/.well-known/traffic-api"), server, cm, emptyArray() + ) + } catch (e: MalformedURLException) { + Result(null, Error(0, R.string.error_url, R.drawable.error_url)) + } +} + +suspend fun getFeeds(cm: ConnectivityManager, server: Server): Result { + return try { + rawRequest( + URL("${server.apiPath}/"), server, cm, arrayOf(1u) + ) + } catch (_: MalformedURLException) { + Result(null, Error(0, R.string.error_url, R.drawable.error_url)) + } +} + +suspend fun queryQueryables( + cm: ConnectivityManager, server: Server, query: String, limit: Int? = null +): Result { + val params = mutableMapOf("q" to query) + if (limit != null) { + params["limit"] = limit.toString() + } + return request(server, "queryables", params, cm, arrayOf(1u)) +} + +suspend fun locateQueryables(cm: ConnectivityManager, server: Server, near: PositionV1): Result { + return request(server, "queryables", mapOf("near" to near.toString()), cm, arrayOf(1u)) +} + +suspend fun getLocatablesIn( + cm: ConnectivityManager, server: Server, bl: PositionV1, tr: PositionV1 +): Result { + return request( + server, "locatables", mapOf("lb" to bl.toString(), "rt" to tr.toString()), cm, arrayOf(1u) + ) +} + +suspend fun getDepartures( + cm: ConnectivityManager, server: Server, stop: String, line: String? = null +): Result { + val params = mutableMapOf("code" to stop) + if (line != null) { + params["line"] = line + } + return request(server, "departures", params, cm, arrayOf(1u)) +} + +suspend fun rawRequest( + url: URL, server: Server, cm: ConnectivityManager, responseVersion: Array<UInt> +): Result { + @Suppress("DEPRECATION") // fixme later(API_29, API_23) https://developer.android.com/reference/android/net/ConnectivityManager#getActiveNetwork() + if (cm.activeNetworkInfo == null) { + return Result(null, Error(0, R.string.error_offline, R.drawable.error_net)) + } + return withContext(Dispatchers.IO) { + val c = (url.openConnection() as HttpURLConnection).apply { + setRequestProperty("X-Bimba-Token", server.token) + responseVersion.forEach { addRequestProperty("Accept", "application/$it+bare") } + } + try { + if (c.responseCode == 200) { + Result(c.inputStream, null) + } else { + val (string, image) = when (c.responseCode) { + 400 -> Pair(R.string.error_400, R.drawable.error_app) + 401 -> Pair(R.string.error_401, R.drawable.error_sec) + 403 -> Pair(R.string.error_403, R.drawable.error_sec) + 404 -> Pair(R.string.error_404, R.drawable.error_search) + 429 -> Pair(R.string.error_429, R.drawable.error_limit) + 500 -> Pair(R.string.error_50x, R.drawable.error_server) + 502 -> Pair(R.string.error_50x, R.drawable.error_server) + 503 -> Pair(R.string.error_50x, R.drawable.error_server) + 504 -> Pair(R.string.error_50x, R.drawable.error_server) + else -> Pair(R.string.error_unknown, R.drawable.error_other) + } + Result(c.errorStream, Error(c.responseCode, string, image)) + } + } catch (e: IOException) { + Result(null, Error(0, R.string.error_connecting, R.drawable.error_server)) + } + } +} + +suspend fun request( + server: Server, + resource: String, + params: Map<String, String>, + cm: ConnectivityManager, + responseVersion: Array<UInt> +): Result { + return withContext(Dispatchers.IO) { + val url = URL( // todo [3.1] scheme, host, path, constructed query + "${server.apiPath}/${server.feeds}/$resource${ + params.map { + "${it.key}=${ + URLEncoder.encode( + it.value, "utf-8" + ) + }" + }.joinToString("&", "?") + }" + ) + rawRequest(url, server, cm, responseVersion) + } +} + +fun hostWithScheme(host: String): String = + if (host.startsWith("http://") or host.startsWith("https://")) { + host + } else { + "https://$host" + } \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Responses.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Responses.kt new file mode 100644 index 0000000000000000000000000000000000000000..e8dd16c9f3c4b61219bd49d49bee82ed08978bd4 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Responses.kt @@ -0,0 +1,272 @@ +package xyz.apiote.bimba.czwek.api + +import xyz.apiote.fruchtfleisch.Reader +import java.io.InputStream + +class UnknownResponseVersion(val resource: String, val version: ULong) : Exception() + +interface DeparturesResponse { + companion object { + fun unmarshal(stream: InputStream): DeparturesResponse { + val reader = Reader(stream) + return when (val v = reader.readUInt().toULong()) { + 0UL -> { + DeparturesResponseDev.unmarshal(stream) + } + 1UL -> { + DeparturesResponseV1.unmarshal(stream) + } + else -> { + throw UnknownResponseVersion("Departures", v) + } + } + } + } +} + +data class DeparturesResponseV1( + val alerts: List<AlertV1>, + val departures: List<DepartureV1>, + val stop: StopV1 +) : DeparturesResponse { + companion object { + fun unmarshal(stream: InputStream): DeparturesResponseV1 { + val alerts = mutableListOf<AlertV1>() + val departures = mutableListOf<DepartureV1>() + + val reader = Reader(stream) + val alertsNum = reader.readUInt().toULong() + for (i in 0UL until alertsNum) { + val alert = AlertV1.unmarshal(stream) + alerts.add(alert) + } + val departuresNum = reader.readUInt().toULong() + for (i in 0UL until departuresNum) { + val departure = DepartureV1.unmarshal(stream) + departures.add(departure) + } + + return DeparturesResponseV1(alerts, departures, StopV1.unmarshal(stream)) + } + } +} + +data class DeparturesResponseDev( + val alerts: List<AlertV1>, + val departures: List<DepartureV1>, + val stop: StopV1 +) : DeparturesResponse { + companion object { + fun unmarshal(stream: InputStream): DeparturesResponseDev { + val alerts = mutableListOf<AlertV1>() + val departures = mutableListOf<DepartureV1>() + + val reader = Reader(stream) + val alertsNum = reader.readUInt().toULong() + for (i in 0UL until alertsNum) { + val alert = AlertV1.unmarshal(stream) + alerts.add(alert) + } + val departuresNum = reader.readUInt().toULong() + for (i in 0UL until departuresNum) { + val departure = DepartureV1.unmarshal(stream) + departures.add(departure) + } + + return DeparturesResponseDev(alerts, departures, StopV1.unmarshal(stream)) + } + } +} + +interface QueryablesResponse { + companion object { + fun unmarshal(stream: InputStream): QueryablesResponse { + val reader = Reader(stream) + return when (val v = reader.readUInt().toULong()) { + 0UL -> { + QueryablesResponseDev.unmarshal(stream) + } + 1UL -> { + QueryablesResponseV1.unmarshal(stream) + } + else -> { + throw UnknownResponseVersion("Queryables", v) + } + } + } + } +} + +data class QueryablesResponseV1(val queryables: List<QueryableV1>) : QueryablesResponse { + companion object { + fun unmarshal(stream: InputStream): QueryablesResponseV1 { + val queryables = mutableListOf<QueryableV1>() + val reader = Reader(stream) + val n = reader.readUInt().toULong() + for (i in 0UL until n) { + when (val r = reader.readUInt().toULong()) { + 0UL -> { + queryables.add(StopV1.unmarshal(stream)) + } + /*1UL -> { + queryables.add(Line.unmarshal(stream)) + }*/ + else -> { + throw UnknownResourceVersion("Queryable/$r", 1u) + } + } + } + return QueryablesResponseV1(queryables) + } + } +} + +data class QueryablesResponseDev(val queryables: List<QueryableV1>) : QueryablesResponse { + companion object { + fun unmarshal(stream: InputStream): QueryablesResponseDev { + val queryables = mutableListOf<QueryableV1>() + val reader = Reader(stream) + val n = reader.readUInt().toULong() + for (i in 0UL until n) { + when (val r = reader.readUInt().toULong()) { + 0UL -> { + queryables.add(StopV1.unmarshal(stream)) + } + /*1UL -> { + queryables.add(Line.unmarshal(stream)) + }*/ + else -> { + throw UnknownResourceVersion("Queryable/$r", 1u) + } + } + } + return QueryablesResponseDev(queryables) + } + } +} + +interface FeedsResponse { + companion object { + fun unmarshal(stream: InputStream): FeedsResponse { + val reader = Reader(stream) + return when (val v = reader.readUInt().toULong()) { + 0UL -> { + FeedsResponseDev.unmarshal(stream) + } + 1UL -> { + FeedsResponseV1.unmarshal(stream) + } + else -> { + throw UnknownResponseVersion("Feeds", v) + } + } + } + } +} + +data class FeedsResponseDev( + val feeds: List<FeedInfoV1> +) : FeedsResponse { + companion object { + fun unmarshal(stream: InputStream): FeedsResponseDev { + val feeds = mutableListOf<FeedInfoV1>() + val reader = Reader(stream) + val n = reader.readUInt().toULong() + for (i in 0UL until n) { + feeds.add(FeedInfoV1.unmarshal(stream)) + } + return FeedsResponseDev(feeds) + } + } +} + +data class FeedsResponseV1( + val feeds: List<FeedInfoV1> +) : FeedsResponse { + companion object { + fun unmarshal(stream: InputStream): FeedsResponseV1 { + val feeds = mutableListOf<FeedInfoV1>() + val reader = Reader(stream) + val n = reader.readUInt().toULong() + for (i in 0UL until n) { + feeds.add(FeedInfoV1.unmarshal(stream)) + } + return FeedsResponseV1(feeds) + } + } +} + +interface LocatablesResponse { + companion object { + fun unmarshal(stream: InputStream): LocatablesResponse { + val reader = Reader(stream) + return when (val v = reader.readUInt().toULong()) { + 0UL -> { + LocatablesResponseDev.unmarshal(stream) + } + 1UL -> { + LocatablesResponseV1.unmarshal(stream) + } + else -> { + throw UnknownResponseVersion("Locatables", v) + } + } + } + } +} + +data class LocatablesResponseV1(val locatables: List<Locatable>) : LocatablesResponse { + companion object { + fun unmarshal(stream: InputStream): LocatablesResponseV1 { + val locatables = mutableListOf<Locatable>() + val reader = Reader(stream) + val n = reader.readUInt().toULong() + for (i in 0UL until n) { + when (val r = reader.readUInt().toULong()) { + 0UL -> { + locatables.add(StopV1.unmarshal(stream)) + } + 1UL -> { + locatables.add(VehicleV1.unmarshal(stream)) + } + else -> { + throw UnknownResourceVersion("Locatable/$r", 1u) + } + } + } + return LocatablesResponseV1(locatables) + } + } +} +data class LocatablesResponseDev(val locatables: List<Locatable>) : LocatablesResponse { + companion object { + fun unmarshal(stream: InputStream): LocatablesResponseDev { + val locatables = mutableListOf<Locatable>() + val reader = Reader(stream) + val n = reader.readUInt().toULong() + for (i in 0UL until n) { + when (val r = reader.readUInt().toULong()) { + 0UL -> { + locatables.add(StopV1.unmarshal(stream)) + } + 1UL -> { + locatables.add(VehicleV1.unmarshal(stream)) + } + else -> { + throw UnknownResourceVersion("Locatable/$r", 1u) + } + } + } + return LocatablesResponseDev(locatables) + } + } +} + +data class ErrorResponse(val field: String, val message: String) { + companion object { + fun unmarshal(stream: InputStream): ErrorResponse { + val reader = Reader(stream) + return ErrorResponse(reader.readString(), reader.readString()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt new file mode 100644 index 0000000000000000000000000000000000000000..cf7adc236dc9653c04b5173789855dd53e10d317 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt @@ -0,0 +1,662 @@ +package xyz.apiote.bimba.czwek.api + +import android.content.Context +import android.graphics.* +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.os.Parcelable +import android.text.format.DateUtils +import android.util.Log +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.ColorUtils.HSLToColor +import androidx.core.graphics.drawable.toBitmap +import kotlinx.parcelize.Parcelize +import org.yaml.snakeyaml.Yaml +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.dpToPixel +import xyz.apiote.bimba.czwek.dpToPixelI +import xyz.apiote.fruchtfleisch.Reader +import java.io.InputStream +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.temporal.ChronoUnit +import java.util.* +import java.util.zip.Adler32 +import kotlin.math.abs +import kotlin.math.pow + +class TrafficFormatException(override val message: String) : IllegalArgumentException() +class UnknownResourceVersion(val resource: String, val version: ULong) : Exception() + +data class BimbaInfo( + val contact: Map<String, String>, +) + +data class Bimba( + val info: BimbaInfo, + val servers: List<Map<String, String>>, + val security: List<Map<String, List<*>>> +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun unmarshal(stream: InputStream): Bimba { + val map = Yaml().load(stream) as HashMap<String, *> + val contact = + if (map["info"] is Map<*, *> && (map["info"] as Map<*, *>).keys.all { it is String }) { + (map["info"] as Map<String, *>)["contact"] + } else { + throw TrafficFormatException("invalid info format") + } + val contactMap = + if (contact is Map<*, *> && contact.all { it.key is String && it.value is String }) { + contact as Map<String, String> + } else { + throw TrafficFormatException("invalid contact format") + } + val servers = if (map["servers"] is List<*> && (map["servers"] as List<*>).all { server -> + server is Map<*, *> && server.all { it.key is String && it.value is String } + }) { + map["servers"] as List<Map<String, String>> + } else { + throw TrafficFormatException("invalid servers format") + } + val security = + if (map["security"] is List<*> && (map["security"] as List<*>).all { security -> + security is Map<*, *> && security.all { it.key is String && it.value is List<*> } + }) { + map["security"] as List<Map<String, List<*>>> + } else { + throw TrafficFormatException("invalid security format") + } + val bimba = Bimba(BimbaInfo(contactMap), servers, security) + bimba.validate() + return bimba + } + } + + fun isPrivate(): Boolean { + return security.size == 1 && security[0]["api_key"] != null + } + + fun isRateLimited(): Boolean { + val items = security.foldRight(0b00) { map, acc -> + acc or when { + map.containsKey("api_key") -> 0b10 + map.isEmpty() -> 0b01 + else -> 0b00 + } + } + Log.i("Rate limited", "${this}, $items") + return security.size == 2 && items == 0b11 + } + + private fun validate() { + if (servers.isEmpty() || servers[0]["url"] == null) { + throw TrafficFormatException("no server in info") + } + if (security.isEmpty()) { + throw TrafficFormatException("no security") + } + } +} + +@Parcelize +data class PositionV1( + val latitude: Double, val longitude: Double +) : Parcelable { + fun isZero(): Boolean { + return latitude == 0.0 && longitude == 0.0 + } + + override fun toString(): String = "$latitude,$longitude" + + + companion object { + fun unmarshal(stream: InputStream): PositionV1 { + val reader = Reader(stream) + return PositionV1( + reader.readFloat64(), reader.readFloat64() + ) + } + } +} + +data class FeedInfoV1( + val name: String, + val id: String, + val attribution: String, + val description: String, + val lastUpdate: ZonedDateTime +) { + companion object { + fun unmarshal(stream: InputStream): FeedInfoV1 { + val reader = Reader(stream) + return FeedInfoV1( + reader.readString(), + reader.readString(), + reader.readString(), + reader.readString(), + ZonedDateTime.parse(reader.readString(), DateTimeFormatter.ISO_DATE_TIME) + ) + } + } + + fun formatDate(): String { + return lastUpdate.format( + DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(Locale.getDefault()) + ) + } +} + +data class AlertV1( + val header: String, + val Description: String, + val Url: String, + val Cause: ULong, // todo [3.1] enum + val Effect: ULong // todo [3.1] enum +) { + companion object { + fun unmarshal(stream: InputStream): AlertV1 { + val reader = Reader(stream) + val header = reader.readString() + val description = reader.readString() + val url = reader.readString() + val cause = reader.readUInt().toULong() + val effect = reader.readUInt().toULong() + return AlertV1(header, description, url, cause, effect) + } + } +} + +data class Time( + val Hour: UInt, val Minute: UInt, val Second: UInt, val DayOffset: Byte, val Zone: String +) { + companion object { + fun unmarshal(stream: InputStream): Time { + val reader = Reader(stream) + return Time( + reader.readUInt().toULong().toUInt(), + reader.readUInt().toULong().toUInt(), + reader.readUInt().toULong().toUInt(), + reader.readI8(), + reader.readString() + ) + } + } +} + +data class ColourV1(val R: UByte, val G: UByte, val B: UByte) { + companion object { + fun unmarshal(stream: InputStream): ColourV1 { + val reader = Reader(stream) + return ColourV1( + reader.readU8(), reader.readU8(), reader.readU8() + ) + } + } + + fun toInt(): Int { + var rgb = 0xff + rgb = (rgb shl 8) + R.toInt() + rgb = (rgb shl 8) + G.toInt() + rgb = (rgb shl 8) + B.toInt() + return rgb + } +} + +data class VehicleV1( + val ID: String, + val Position: PositionV1, + val Capabilities: UShort, + val Speed: Float, + val Line: LineStubV1, + val Headsign: String, + val CongestionLevel: ULong, + val OccupancyStatus: ULong +) : Locatable { + enum class Capability(val bit: UShort) { + RAMP(0b0001u), LOW_FLOOR(0b0010u), LOW_ENTRY(0b0001_0000_0000u), AC(0b0100u), BIKE(0b1000u), VOICE( + 0b0001_0000u + ), + TICKET_MACHINE(0b0010_0000u), TICKET_DRIVER(0b0100_0000u), USB_CHARGING(0b1000_0000u) + } + + override fun id(): String = ID + + override fun icon(context: Context, scale: Float): Drawable { + return BitmapDrawable(context.resources, Line.icon(context, scale)) + } + + override fun location(): PositionV1 = Position + + fun congestion(context: Context): String { + return when (val r = CongestionLevel.toUInt()) { // todo [3.1] enum + 0u -> context.getString(R.string.congestion_unknown) + 1u -> context.getString(R.string.congestion_smooth) + 2u -> context.getString(R.string.congestion_stop_and_go) + 3u -> context.getString(R.string.congestion_congestion) + 4u -> context.getString(R.string.congestion_jams) + else -> throw UnknownResourceVersion("Congestion/$r", 1u) + } + } + + fun occupancy(context: Context): String { + return when (val r = OccupancyStatus.toUInt()) { // todo [3.1] enum + 0u -> context.getString(R.string.occupancy_unknown) + 1u -> context.getString(R.string.occupancy_empty) + 2u -> context.getString(R.string.occupancy_many_seats) + 3u -> context.getString(R.string.occupancy_few_seats) + 4u -> context.getString(R.string.occupancy_standing_only) + 5u -> context.getString(R.string.occupancy_crowded) + 6u -> context.getString(R.string.occupancy_full) + 7u -> context.getString(R.string.occupancy_wont_let) + else -> throw UnknownResourceVersion("Occupancy/$r", 1u) + } + } + + companion object { + fun unmarshal(stream: InputStream): VehicleV1 { + val reader = Reader(stream) + return VehicleV1( + reader.readString(), + PositionV1.unmarshal(stream), + reader.readU16(), + reader.readFloat32(), + LineStubV1.unmarshal(stream), + reader.readString(), + reader.readUInt().toULong(), + reader.readUInt().toULong() + ) + } + } + + fun getCapability(field: Capability): Boolean { + return Capabilities.and(field.bit) != (0).toUShort() + } +} + +data class LineStubV1( + val name: String, val kind: LineTypeV1, val colour: ColourV1 +) : LineAbstract { + companion object { + fun unmarshal(stream: InputStream): LineStubV1 { + val reader = Reader(stream) + return LineStubV1( + reader.readString(), + LineTypeV1.of(reader.readUInt().toULong().toUInt()), + ColourV1.unmarshal(stream) + ) + } + } + + fun icon(context: Context, scale: Float = 1f): Bitmap { + return super.icon(context, kind, colour, scale) + } +} + +data class DepartureV1( + val ID: String, + val time: Time, + val status: ULong, + val isRealtime: Boolean, + val vehicle: VehicleV1, + val boarding: UByte +) { + + fun statusText(context: Context?): String { + val now = Instant.now().atZone(ZoneId.systemDefault()) + val departureTime = ZonedDateTime.of( + now.year, now.monthValue, now.dayOfMonth, + time.Hour.toInt(), time.Minute.toInt(), time.Second.toInt(), 0, ZoneId.of(time.Zone) + ).plus(time.DayOffset.toLong(), ChronoUnit.DAYS) + return when (val r = status.toUInt()) { + 0u -> DateUtils.getRelativeTimeSpanString( + departureTime.toEpochSecond() * 1000, + now.toEpochSecond() * 1000, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + 1u -> context?.getString(R.string.departure_momentarily) ?: "momentarily" + 2u -> context?.getString(R.string.departure_now) ?: "now" + 3u -> context?.getString(R.string.departure_departed) ?: "departed" + else -> throw UnknownResourceVersion("VehicleStatus/$r", 1u) + } + } + + fun timeString(context: Context): String { + return if (isRealtime) { + context.getString( + R.string.at_time_realtime, time.Hour.toInt(), time.Minute.toInt(), time.Second.toInt() + ) + } else { + context.getString(R.string.at_time, time.Hour.toInt(), time.Minute.toInt()) + } + } + + fun boardingText(context: Context): String { + // todo [3.x] probably should take into account (on|off)-boarding only, on demand + return when { + boarding == (0b0000_0000).toUByte() -> context.getString(R.string.no_boarding) + boarding.and(0b0011_0011u) == (0b0000_0001).toUByte() -> context.getString(R.string.on_boarding) + boarding.and(0b0011_0011u) == (0b0001_0000).toUByte() -> context.getString(R.string.off_boarding) + boarding.and(0b0011_0011u) == (0b0001_0001).toUByte() -> context.getString(R.string.boarding) + else -> context.getString(R.string.on_demand) + } + } + + companion object { + fun unmarshal(stream: InputStream): DepartureV1 { + val reader = Reader(stream) + val id = reader.readString() + val time = Time.unmarshal(stream) + val status = reader.readUInt().toULong() + val isRealtime = reader.readBoolean() + val vehicle = VehicleV1.unmarshal(stream) + val boarding = reader.readU8() + return DepartureV1(id, time, status, isRealtime, vehicle, boarding) + } + } +} + +interface QueryableV1 +interface Locatable { + fun icon(context: Context, scale: Float = 1f): Drawable + fun location(): PositionV1 + fun id(): String +} + +class ErrorLocatable(val stringResource: Int) : Locatable { + override fun icon(context: Context, scale: Float): Drawable { + return AppCompatResources.getDrawable(context, R.drawable.error_other)!! + } + + override fun location(): PositionV1 { + return PositionV1(0.0, 0.0) + } + + override fun id(): String { + return "ERROR" + } +} + +@Parcelize +data class StopV1( + val code: String, + val name: String, + val zone: String, + val position: PositionV1, + val changeOptions: List<ChangeOptionV1> +) : QueryableV1, Locatable, Parcelable { + + override fun icon(context: Context, scale: Float): Drawable { + val saturationArray = arrayOf(0.5f, 0.65f, 0.8f) + val sal = saturationArray.size + val lightnessArray = arrayOf(.5f) + val lal = lightnessArray.size + val md = Adler32().let { + it.update(name.toByteArray()) + it.value + } + val h = md % 359f + val s = saturationArray[(md / 360 % sal).toInt()] + val l = lightnessArray[(md / 360 / sal % lal).toInt()] + val fg = AppCompatResources.getDrawable(context, R.drawable.stop) + val bg = AppCompatResources.getDrawable(context, R.drawable.stop_bg)!!.mutate().apply { + setTint(HSLToColor(arrayOf(h, s, l).toFloatArray())) + } + return BitmapDrawable( + context.resources, + LayerDrawable(arrayOf(bg, fg)).mutate() + .toBitmap(dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888) + ) + } + + override fun id(): String = code + + override fun location(): PositionV1 = position + + override fun toString(): String { + var result = "$name ($code) [$zone] $position\n" + for (chOpt in changeOptions) result += "${chOpt.line} → ${chOpt.headsign}\n" + return result + } + + fun changeOptions(context: Context): Pair<String, String> { + return Pair(changeOptions.groupBy { it.line } + .map { Pair(it.key, it.value.joinToString { co -> co.headsign }) }.joinToString { + context.getString( + R.string.vehicle_headsign, it.first, it.second + ) + }, + changeOptions.groupBy { it.line } + .map { Pair(it.key, it.value.joinToString { co -> co.headsign }) }.joinToString { + context.getString( + R.string.vehicle_headsign_content_description, it.first, it.second + ) + }) + } + + companion object { + fun unmarshal(stream: InputStream): StopV1 { + val reader = Reader(stream) + val code = reader.readString() + val name = reader.readString() + val zone = reader.readString() + val position = PositionV1.unmarshal(stream) + val chOptionsNum = reader.readUInt().toULong() + val changeOptions = mutableListOf<ChangeOptionV1>() + for (i in 0UL until chOptionsNum) { + changeOptions.add(ChangeOptionV1.unmarshal(stream)) + } + return StopV1( + name = name, code = code, zone = zone, position = position, changeOptions = changeOptions + ) + } + } +} + +interface LineAbstract { + fun textColour(c: ColourV1): Int { + val black = relativeLuminance(ColourV1(0u, 0u, 0u)) + .05 + val white = relativeLuminance(ColourV1(255u, 255u, 255u)) + .05 + val colour = relativeLuminance(c) + .05 + return if ((white / colour) > (colour / black)) { + Color.WHITE + } else { + Color.BLACK + } + } + + private fun relativeLuminance(colour: ColourV1): Double { + val r = fromSRGB(colour.R.toDouble() / 0xff) + val g = fromSRGB(colour.G.toDouble() / 0xff) + val b = fromSRGB(colour.B.toDouble() / 0xff) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + + private fun fromSRGB(part: Double): Double { + return if (part <= 0.03928) { + part / 12.92 + } else { + ((part + 0.055) / 1.055).pow(2.4) + } + } + + fun icon(context: Context, type: LineTypeV1, colour: ColourV1, scale: Float): Bitmap { + val drawingBitmap = Bitmap.createBitmap( + dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(drawingBitmap) + + canvas.drawPath(getSquirclePath( + dpToPixel(.8f / scale), dpToPixel(.8f / scale), dpToPixelI(11.2f / scale) + ), Paint().apply { color = textColour(colour) }) + canvas.drawPath(getSquirclePath( + dpToPixel(1.6f / scale), dpToPixel(1.6f / scale), dpToPixelI(10.4f / scale) + ), Paint().apply { color = colour.toInt() }) + + val iconID = when (type) { + LineTypeV1.BUS -> R.drawable.bus_black + LineTypeV1.TRAM -> R.drawable.tram_black + LineTypeV1.UNKNOWN -> R.drawable.vehicle_black + } + val icon = AppCompatResources.getDrawable(context, iconID)?.mutate()?.apply { + setTint(textColour(colour)) + }?.toBitmap(dpToPixelI(19.2f / scale), dpToPixelI(19.2f / scale), Bitmap.Config.ARGB_8888) + canvas.drawBitmap( + icon!!, dpToPixel(2.4f / scale), dpToPixel(2.4f / scale), Paint() + ) + return drawingBitmap + } + + private fun getSquirclePath( + left: Float, top: Float, radius: Int + ): Path { + val radiusToPow = (radius * radius * radius).toDouble() + val path = Path() + path.moveTo(-radius.toFloat(), 0f) + for (x in -radius..radius) path.lineTo( + x.toFloat(), Math.cbrt(radiusToPow - abs(x * x * x)).toFloat() + ) + for (x in radius downTo -radius) path.lineTo( + x.toFloat(), -Math.cbrt(radiusToPow - abs(x * x * x)).toFloat() + ) + path.close() + val matrix = Matrix() + matrix.postTranslate((left + radius), (top + radius)) + path.transform(matrix) + return path + } +} + +data class Line( + val colour: ColourV1, + val type: LineTypeV1, + val headsignsThere: List<String>, + val headsignsBack: List<String>, + val graphThere: LineGraph, + val graphBack: LineGraph, + val name: String +) : QueryableV1, LineAbstract { + override fun toString(): String { + return "$name ($type) [$colour]\n→ [${headsignsThere.joinToString()}]\n→ [${headsignsBack.joinToString()}]\n" + } + + fun icon(context: Context, scale: Float = 1f): Bitmap { + return super.icon(context, type, colour, scale) + } + + companion object { + fun unmarshal(stream: InputStream): Line { + val reader = Reader(stream) + val colour = ColourV1.unmarshal(stream) + val type = reader.readUInt() + val headsignsThereNum = reader.readUInt().toULong() + val headsignsThere = mutableListOf<String>() + for (i in 0UL until headsignsThereNum) { + headsignsThere.add(reader.readString()) + } + val headsignsBackNum = reader.readUInt().toULong() + val headsignsBack = mutableListOf<String>() + for (i in 0UL until headsignsBackNum) { + headsignsBack.add(reader.readString()) + } + val graphThere = LineGraph.unmarshal(stream) + val graphBack = LineGraph.unmarshal(stream) + val name = reader.readString() + return Line( + name = name, + colour = colour, + type = LineTypeV1.of(type.toULong().toUInt()), + headsignsThere = headsignsThere, + headsignsBack = headsignsBack, + graphThere = graphThere, + graphBack = graphBack + ) + } + } +} + +enum class LineTypeV1 { + UNKNOWN, TRAM, BUS; + + companion object { + fun of(type: UInt): LineTypeV1 { + return when (type) { + 0u -> valueOf("UNKNOWN") + 1u -> valueOf("TRAM") + 2u -> valueOf("BUS") + else -> throw UnknownResourceVersion("LineType/$type", 1u) + } + } + } +} + +@Parcelize +data class ChangeOptionV1(val line: String, val headsign: String):Parcelable { + companion object { + fun unmarshal(stream: InputStream): ChangeOptionV1 { + val reader = Reader(stream) + return ChangeOptionV1(line = reader.readString(), headsign = reader.readString()) + } + } +} + +data class LineGraph( + val stops: List<StopStub>, + val nextNodes: Map<Long, List<Long>>, + val prevNodes: Map<Long, List<Long>> +) { + companion object { + fun unmarshal(stream: InputStream): LineGraph { + val reader = Reader(stream) + val stopsNum = reader.readUInt().toULong() + val stops = mutableListOf<StopStub>() + for (i in 0UL until stopsNum) { + stops.add(StopStub.unmarshal(stream)) + } + val nextNodesNum = reader.readUInt().toULong() + val nextNodes = mutableMapOf<Long, List<Long>>() + for (i in 0UL until nextNodesNum) { + val from = reader.readInt().toLong() + val toNum = reader.readUInt().toULong() + val to = mutableListOf<Long>() + for (j in 0UL until toNum) { + to.add(reader.readInt().toLong()) + } + nextNodes[from] = to + } + val prevNodesNum = reader.readUInt().toULong() + val prevNodes = mutableMapOf<Long, List<Long>>() + for (i in 0UL until prevNodesNum) { + val from = reader.readInt().toLong() + val toNum = reader.readUInt().toULong() + val to = mutableListOf<Long>() + for (j in 0UL until toNum) { + to.add(reader.readInt().toLong()) + } + prevNodes[from] = to + } + + return LineGraph(stops = stops, nextNodes = nextNodes, prevNodes = prevNodes) + } + } +} + +data class StopStub(val name: String, val code: String, val zone: String, val onDemand: Boolean) { + companion object { + fun unmarshal(stream: InputStream): StopStub { + val reader = Reader(stream) + return StopStub( + code = reader.readString(), + name = reader.readString(), + zone = reader.readString(), + onDemand = reader.readBoolean() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..d9948e433a7a53bcfc8b905ad9771c7e84603820 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt @@ -0,0 +1,202 @@ +package xyz.apiote.bimba.czwek.dashboard + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.view.WindowCompat +import androidx.core.view.get +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController +import com.google.android.material.bottomnavigation.BottomNavigationView +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.Line +import xyz.apiote.bimba.czwek.api.QueryableV1 +import xyz.apiote.bimba.czwek.api.StopV1 +import xyz.apiote.bimba.czwek.dashboard.ui.home.HomeFragment +import xyz.apiote.bimba.czwek.dashboard.ui.map.MapFragment +import xyz.apiote.bimba.czwek.dashboard.ui.voyage.VoyageFragment +import xyz.apiote.bimba.czwek.databinding.ActivityMainBinding +import xyz.apiote.bimba.czwek.departures.DeparturesActivity +import xyz.apiote.bimba.czwek.search.ResultsActivity +import xyz.apiote.bimba.czwek.settings.ServerChooserActivity +import xyz.apiote.bimba.czwek.settings.feeds.FeedChooserActivity + + +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + private lateinit var locationPermissionRequest: ActivityResultLauncher<Array<String>> + + private lateinit var permissionAsker: Fragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + getSharedPreferences("shp", MODE_PRIVATE).edit(true) { + putBoolean("firstRun", false) + } + + supportFragmentManager.registerFragmentLifecycleCallbacks( + object : FragmentLifecycleCallbacks() { + override fun onFragmentViewCreated( + fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle? + ) { + setNavbarIcons(f) + super.onFragmentViewCreated(fm, f, v, savedInstanceState) + } + }, true + ) + + binding.navigationDrawer.setNavigationItemSelectedListener { + when (it.itemId) { + R.id.drawer_servers -> { + startActivity(Intent(this, ServerChooserActivity::class.java)) + } + R.id.drawer_cities -> { + startActivity(Intent(this, FeedChooserActivity::class.java)) + } + } + false + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + val navView: BottomNavigationView = binding.bottomNavigation + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_main) as NavHostFragment + val navController = navHostFragment.navController + navView.setupWithNavController(navController) + + locationPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + when { + permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false || + permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false -> { + when (permissionAsker) { + is HomeFragment -> { + showResults(ResultsActivity.Mode.MODE_LOCATION) + } + is MapFragment -> { + (permissionAsker as MapFragment).showLocation() + } + } + } + else -> { + // todo(ux,ui) dialog + Toast.makeText(this, "No location access given", Toast.LENGTH_SHORT).show() + } + } + } + } + + @Suppress( + "OVERRIDE_DEPRECATION", + "DEPRECATION" + ) // fixme later https://developer.android.com/reference/androidx/activity/OnBackPressedDispatcher + override fun onBackPressed() { + if (binding.container.isDrawerOpen(binding.navigationDrawer)) { + binding.container.closeDrawer(binding.navigationDrawer) + } else { + super.onBackPressed() + } + } + + fun onNavigationClicked() { + if (binding.container.isDrawerOpen(binding.navigationDrawer)) { + binding.container.closeDrawer(binding.navigationDrawer) + } else { + binding.container.openDrawer(binding.navigationDrawer) + } + } + + fun onGpsClicked(fragment: Fragment) { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) -> { + when (fragment) { + is HomeFragment -> { + showResults(ResultsActivity.Mode.MODE_LOCATION) + } + is MapFragment -> { + fragment.showLocation() + } + } + } + else -> { + permissionAsker = fragment + locationPermissionRequest.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + } + } + + fun onSuggestionClicked(queryable: QueryableV1) { + when (queryable) { + is StopV1 -> { + val intent = Intent(this, DeparturesActivity::class.java).apply { + putExtra("code", queryable.code) + putExtra("name", queryable.name) + } + startActivity(intent) + } + is Line -> { + TODO("[3.1] start line graph activity") + } + } + } + + fun onSearchClicked(text: CharSequence?) { + showResults(ResultsActivity.Mode.MODE_SEARCH, text.toString()) + } + + private fun showResults(mode: ResultsActivity.Mode, query: String = "") { + /* todo [3.1] (ux,low) animation + https://developer.android.com/guide/fragments/animate + https://github.com/raheemadamboev/fab-explosion-animation-app + */ + val intent = Intent(this, ResultsActivity::class.java).apply { + putExtra("mode", mode) + putExtra("query", query) + } + startActivity(intent) + } + + private fun setNavbarIcons(f: Fragment) { + // todo [voyage-planning] + // binding.bottomNavigation.menu[2].setIcon(R.drawable.voyage_outline) + binding.bottomNavigation.menu[1].setIcon(R.drawable.home_outline) + binding.bottomNavigation.menu[0].setIcon(R.drawable.map_outline) + when (f) { + is HomeFragment -> { + binding.bottomNavigation.menu[1].setIcon(R.drawable.home_black) + } + is VoyageFragment -> { + binding.bottomNavigation.menu[2].setIcon(R.drawable.voyage_black) + } + is MapFragment -> { + binding.bottomNavigation.menu[0].setIcon(R.drawable.map_black) + } + else -> { + binding.bottomNavigation.menu[1].setIcon(R.drawable.home_black) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..816339a9a11fdffc30fa1df3a6b2de20c054aefc --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt @@ -0,0 +1,92 @@ +package xyz.apiote.bimba.czwek.dashboard.ui.home + +import android.content.Context +import android.net.ConnectivityManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import com.mancj.materialsearchbar.MaterialSearchBar +import com.mancj.materialsearchbar.MaterialSearchBar.BUTTON_NAVIGATION +import xyz.apiote.bimba.czwek.api.QueryableV1 +import xyz.apiote.bimba.czwek.dashboard.MainActivity +import xyz.apiote.bimba.czwek.databinding.FragmentHomeBinding +import xyz.apiote.bimba.czwek.search.BimbaSuggestionsAdapter + +// todo [3.1] search: https://github.com/material-components/material-components-android/blob/master/docs/components/Search.md + +class HomeFragment : Fragment() { + private var _binding: FragmentHomeBinding? = null + private val binding get() = _binding!! + + private var lastSuggestions = listOf<QueryableV1>() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentHomeBinding.inflate(inflater, container, false) + + val homeViewModel = + ViewModelProvider(this)[HomeViewModel::class.java] + homeViewModel.queryables.observe(viewLifecycleOwner) { + binding.searchBar.updateLastSuggestions(it) + } + + val root = binding.root + ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.layoutParams = (view.layoutParams as FrameLayout.LayoutParams).apply { + topMargin = insets.top + } + WindowInsetsCompat.CONSUMED + } + + binding.searchBar.lastSuggestions = lastSuggestions + val cm = requireContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + binding.searchBar.addTextChangeListener( + homeViewModel.SearchBarWatcher(requireContext(), cm) + ) + binding.searchBar.setOnSearchActionListener(object : MaterialSearchBar.OnSearchActionListener { + override fun onButtonClicked(buttonCode: Int) { + when (buttonCode) { + BUTTON_NAVIGATION -> { + (context as MainActivity).onNavigationClicked() + } + } + } + + override fun onSearchStateChanged(enabled: Boolean) { + } + + override fun onSearchConfirmed(text: CharSequence?) { + binding.searchBar.clearSuggestions() + (context as MainActivity).onSearchClicked(text) + } + }) + binding.searchBar.setCardViewElevation(0) + binding.searchBar.setCustomSuggestionAdapter(BimbaSuggestionsAdapter(layoutInflater, context) { + binding.searchBar.clearSuggestions() + (context as MainActivity).onSuggestionClicked(it) + }) + + binding.floatingActionButton.setOnClickListener { + binding.searchBar.clearSuggestions() + (context as MainActivity).onGpsClicked(this) + } + // todo [3.1] (ux,low) on searchbar focus && if != '' -> populate suggestions + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..72f6efd2bb9b254905f1172adef1f9ac98a07ba0 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt @@ -0,0 +1,88 @@ +package xyz.apiote.bimba.czwek.dashboard.ui.home + +import android.content.Context +import android.net.ConnectivityManager +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import xyz.apiote.bimba.czwek.api.* + +class HomeViewModel : ViewModel() { + private val mutableQueryables = MutableLiveData<List<QueryableV1>>() + val queryables: LiveData<List<QueryableV1>> = mutableQueryables + + fun getQueryables(cm: ConnectivityManager, server: Server, query: String) { + viewModelScope.launch { + val result = queryQueryables(cm, server, query, limit = 6) + if (result.error != null) { + // note intentionally no error showing in suggestions + if (result.stream != null) { + Log.e("Suggestion", "${b2s(result.stream.readBytes())}") + return@launch + Log.e( + "Suggestion", + "${result.error.statusCode}, ${ErrorResponse.unmarshal(result.stream).message}" + ) + } else { + Log.e("Suggestion", "${result.error.statusCode}") + } + } else { + mutableQueryables.value = + when (val response = QueryablesResponse.unmarshal(result.stream!!)) { + is QueryablesResponseDev -> response.queryables + is QueryablesResponseV1 -> response.queryables + else -> null + } + } + } + } + + private fun b2s(b: ByteArray): String { + var s = "" + b.forEach { + if (it in 32..127) { + s += Char(it.toInt()) + } else { + s += "\\x$it" + } + } + return s + } + + inner class SearchBarWatcher( + private val context: Context, + private val cm: ConnectivityManager + ) : + TextWatcher { + private val handler = Handler(Looper.getMainLooper()) + private var workRunnable = Runnable {} + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + } + + override fun afterTextChanged(s: Editable?) { + handler.removeCallbacks(workRunnable) + workRunnable = Runnable { + val text = s.toString() + getQueryables( + cm, + Server.get(context), text + ) + } + handler.postDelayed( + workRunnable, + 750 + ) // todo(ux,low) make good time (probably between 500, 1000ms) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..b688de9e98fd5a9be8c9e142a98faa029b3c5ca8 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt @@ -0,0 +1,244 @@ +package xyz.apiote.bimba.czwek.dashboard.ui.map + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences +import android.content.res.Configuration.* +import android.graphics.Bitmap +import android.net.ConnectivityManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.edit +import androidx.core.graphics.drawable.toBitmap +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.snackbar.Snackbar +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.ErrorLocatable +import xyz.apiote.bimba.czwek.api.PositionV1 +import xyz.apiote.bimba.czwek.api.Server +import xyz.apiote.bimba.czwek.dashboard.MainActivity +import xyz.apiote.bimba.czwek.databinding.FragmentMapBinding +import xyz.apiote.bimba.czwek.dpToPixelI +import org.osmdroid.config.Configuration +import org.osmdroid.events.MapListener +import org.osmdroid.events.ScrollEvent +import org.osmdroid.events.ZoomEvent +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.TilesOverlay +import org.osmdroid.views.overlay.gestures.RotationGestureOverlay +import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay + +// todo[3.1] empty state on no network + +class MapFragment : Fragment() { + + private var maybeBinding: FragmentMapBinding? = null + private val binding get() = maybeBinding!! + + private lateinit var locationOverlay: MyLocationNewOverlay + private lateinit var mapViewModel: MapViewModel + + private val handler = Handler(Looper.getMainLooper()) + private var workRunnable = Runnable {} + + private var snack: Snackbar? = null + + @SuppressLint("ClickableViewAccessibility") + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + mapViewModel = + ViewModelProvider(this)[MapViewModel::class.java] + + observeLocatables() + + maybeBinding = FragmentMapBinding.inflate(inflater, container, false) + val root: View = binding.root + + binding.map.setTileSource(TileSourceFactory.MAPNIK) + if (((context?.resources?.configuration?.uiMode ?: UI_MODE_NIGHT_UNDEFINED) + and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES + ) { + binding.map.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS) + } + binding.map.zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + binding.map.setMultiTouchControls(true) + binding.map.overlays.add(RotationGestureOverlay(binding.map).apply { isEnabled = true }) + + locationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map) + context?.let { + centreMap(it.getSharedPreferences("shp", MODE_PRIVATE)) + + locationOverlay.setDirectionIcon( + AppCompatResources.getDrawable(it, R.drawable.navigation_arrow)?.mutate() + ?.toBitmap(dpToPixelI(36f), dpToPixelI(36f), Bitmap.Config.ARGB_8888) + ) + locationOverlay.setDirectionAnchor(.5f, .5f) + locationOverlay.setPersonIcon( + AppCompatResources.getDrawable(it, R.drawable.navigation_circle)?.mutate() + ?.toBitmap(dpToPixelI(24f), dpToPixelI(24f), Bitmap.Config.ARGB_8888) + ) + locationOverlay.setPersonAnchor(.5f, .5f) + } + + binding.floatingActionButton.setOnClickListener { + (context as MainActivity).onGpsClicked(this) + } + + binding.map.addMapListener(object : MapListener { + override fun onScroll(event: ScrollEvent?): Boolean { + return onMapMove() + } + + override fun onZoom(event: ZoomEvent?): Boolean { + return onMapMove() + } + }) + + binding.map.setOnTouchListener { _, _ -> + binding.floatingActionButton.show() + false + } + + return root + } + + private fun onMapMove(): Boolean { + snack?.dismiss() + return delayGetLocatables() + } + + private fun delayGetLocatables(delay: Long = 1000): Boolean { + handler.removeCallbacks(workRunnable) + workRunnable = Runnable { + getLocatables() + } + handler.postDelayed(workRunnable, delay) + return true + } + + private fun observeLocatables() { + mapViewModel.locatables.observe(viewLifecycleOwner) { + binding.map.overlays.removeAll { marker -> + marker is Marker + } + + if (it.size == 1 && it[0] is ErrorLocatable) { + Snackbar.make(binding.root, (it[0] as ErrorLocatable).stringResource, Snackbar.LENGTH_LONG).show() + return@observe + } + + it.forEach { locatable -> + val marker = Marker(binding.map) + marker.position = GeoPoint(locatable.location().latitude, locatable.location().longitude) + marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + marker.icon = context?.let { ctx -> locatable.icon(ctx, 2f) } + + context?.let { ctx -> + marker.setOnMarkerClickListener { _, _ -> + MapBottomSheet(locatable).apply { + (ctx as MainActivity?)?.supportFragmentManager?.let { fm -> + show(fm, MapBottomSheet.TAG) + } + } + true + } + } + binding.map.overlays.add(marker) + } + + binding.map.invalidate() + } + } + + fun showLocation() { + snack = + Snackbar.make(binding.root, getString(R.string.waiting_position), Snackbar.LENGTH_INDEFINITE) + snack!!.show() + binding.floatingActionButton.hide() + binding.map.overlays.removeAll { + it is MyLocationNewOverlay + } + locationOverlay.enableFollowLocation() + binding.map.overlays.add(locationOverlay) + locationOverlay.runOnFirstFix { + snack?.dismiss() + } + } + + private fun getLocatables() { + maybeBinding?.let { binding -> + val (bl, tr) = binding.map.boundingBox.let { + Pair( + PositionV1(it.latSouth, it.lonWest), + PositionV1(it.latNorth, it.lonEast) + ) + } + context?.let { + val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + mapViewModel.getLocatablesIn( + cm, + Server.get(it), bl, tr + ) + } + delayGetLocatables(30000) + } + } + + private fun centreMap(preferences: SharedPreferences) { + maybeBinding?.map?.controller?.apply { + setZoom(preferences.getFloat("mapZoom", 17.0f).toDouble()) + val startPoint = GeoPoint( + preferences.getFloat("mapCentreLat", 52.39511f).toDouble(), + preferences.getFloat("mapCentreLon", 16.89506f).toDouble() + ) + setCenter(startPoint) + } + } + + override fun onResume() { + super.onResume() + binding.map.onResume() + locationOverlay.enableMyLocation() + context?.let { ctx -> + ctx.getSharedPreferences("shp", MODE_PRIVATE).let { + Configuration.getInstance() + .load(ctx, it) + centreMap(it) + } + } + } + + override fun onPause() { + super.onPause() + binding.map.onPause() + locationOverlay.disableMyLocation() + val centre = binding.map.mapCenter + context?.let { ctx -> + ctx.getSharedPreferences("shp", MODE_PRIVATE).edit(true) { + this.putFloat("mapCentreLat", centre.latitude.toFloat()) + this.putFloat("mapCentreLon", centre.longitude.toFloat()) + this.putFloat("mapZoom", binding.map.zoomLevelDouble.toFloat()) + } + } + handler.removeCallbacks(workRunnable) + } + + override fun onDestroyView() { + super.onDestroyView() + maybeBinding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..3a22298fe73acead5ecb23f43b9c171fe5c6deee --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt @@ -0,0 +1,170 @@ +package xyz.apiote.bimba.czwek.dashboard.ui.map + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.constraintlayout.widget.Group +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.coroutines.launch +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.* +import xyz.apiote.bimba.czwek.departures.DeparturesActivity + +class MapViewModel : ViewModel() { + + private val _locatables = MutableLiveData<List<Locatable>>() + val locatables: MutableLiveData<List<Locatable>> = _locatables + + fun getLocatablesIn(cm: ConnectivityManager, server: Server, bl: PositionV1, tr: PositionV1) { + viewModelScope.launch { + val result = xyz.apiote.bimba.czwek.api.getLocatablesIn(cm, server, bl, tr) + if (result.error != null) { + _locatables.value = listOf(ErrorLocatable(result.error.stringResource)) + if (result.stream != null) { + Log.w( + "Map", + "${result.error.statusCode}, ${ErrorResponse.unmarshal(result.stream).message}" + ) + } else { + Log.w("Map", "${result.error.statusCode}") + } + return@launch + } else { + _locatables.value = when (val response = LocatablesResponse.unmarshal(result.stream!!)) { + is LocatablesResponseDev -> response.locatables + is LocatablesResponseV1 -> response.locatables + else -> null + } + } + } + } +} + +class MapBottomSheet(private val locatable: Locatable) : BottomSheetDialogFragment() { + companion object { + const val TAG = "MapBottomSheet" + } + + private fun showVehicle(content: View, vehicle: VehicleV1) { + content.findViewById<Group>(R.id.stop_group).visibility = View.GONE + content.findViewById<Group>(R.id.vehicle_group).visibility = View.VISIBLE + + context?.let { ctx -> + content.findViewById<TextView>(R.id.title).apply { + text = ctx.getString(R.string.vehicle_headsign, vehicle.Line.name, vehicle.Headsign) + contentDescription = ctx.getString( + R.string.vehicle_headsign_content_description, + vehicle.Line.name, + vehicle.Headsign + ) + } + // todo units -- [3.1] settings or system-based + content.findViewById<TextView>(R.id.speed_text).text = + ctx.getString(R.string.speed_in_km_per_h, vehicle.Speed * 3.6) + content.findViewById<TextView>(R.id.congestion_text).text = vehicle.congestion(ctx) + content.findViewById<TextView>(R.id.occupancy_text).text = vehicle.occupancy(ctx) + content.findViewById<ImageView>(R.id.ac).visibility = + if (vehicle.getCapability(VehicleV1.Capability.AC)) { + View.VISIBLE + } else { + View.GONE + } + content.findViewById<ImageView>(R.id.bike).visibility = + if (vehicle.getCapability(VehicleV1.Capability.BIKE)) { + View.VISIBLE + } else { + View.GONE + } + content.findViewById<ImageView>(R.id.voice).visibility = + if (vehicle.getCapability(VehicleV1.Capability.VOICE)) { + View.VISIBLE + } else { + View.GONE + } + content.findViewById<ImageView>(R.id.ticket).visibility = + if (vehicle.let { + it.getCapability(VehicleV1.Capability.TICKET_DRIVER) || it.getCapability(VehicleV1.Capability.TICKET_MACHINE) + }) { + View.VISIBLE + } else { + View.GONE + } + content.findViewById<ImageView>(R.id.usb).visibility = + if (vehicle.getCapability(VehicleV1.Capability.USB_CHARGING)) { + View.VISIBLE + } else { + View.GONE + } + } + } + + private fun showStop(content: View, stop: StopV1) { + context?.let { ctx -> + content.findViewById<Group>(R.id.stop_group).visibility = View.VISIBLE + content.findViewById<Group>(R.id.vehicle_group).visibility = View.GONE + content.findViewById<TextView>(R.id.title).text = + context?.getString(R.string.stop_title, stop.name, stop.code) + content.findViewById<Button>(R.id.departures_button).setOnClickListener { + val intent = Intent(ctx, DeparturesActivity::class.java).apply { + putExtra("code", stop.code) + putExtra("name", stop.name) + } + startActivity(intent) + } + content.findViewById<Button>(R.id.navigation_button).setOnClickListener { + try { + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("geo:${stop.location().latitude},${stop.location().longitude}") + ) + ) + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, ctx.getString(R.string.no_map_app), Toast.LENGTH_SHORT).show() + } + } + + stop.changeOptions(ctx).let { changeOptions -> + content.findViewById<TextView>(R.id.change_options).apply { + text = changeOptions.first + contentDescription = changeOptions.second + } + } + } + } + + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val content = inflater.inflate(R.layout.map_bottom_sheet, container, false) + content.apply { + when (locatable) { + is VehicleV1 -> { + showVehicle(this, locatable) + } + is StopV1 -> { + showStop(this, locatable) + } + } + } + //(dialog as BottomSheetDialog).behavior.peekHeight = dpToPixelI(90f) + + return content + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/voyage/VoyageFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/voyage/VoyageFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..0e1f56ca0b9ce6d8a357cec9e38a20e5e64c97fa --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/voyage/VoyageFragment.kt @@ -0,0 +1,42 @@ +package xyz.apiote.bimba.czwek.dashboard.ui.voyage + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import xyz.apiote.bimba.czwek.databinding.FragmentVoyageBinding + +class VoyageFragment : Fragment() { + + private var _binding: FragmentVoyageBinding? = null + + // This property is only valid between onCreateView and + // onDestroyView. + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val voyageViewModel = + ViewModelProvider(this)[VoyageViewModel::class.java] + + _binding = FragmentVoyageBinding.inflate(inflater, container, false) + val root: View = binding.root + + val textView: TextView = binding.textDashboard + voyageViewModel.text.observe(viewLifecycleOwner) { + textView.text = it + } + return root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/voyage/VoyageViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/voyage/VoyageViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..6b1b923755b427a935e8ca9192c5c91136635b93 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/voyage/VoyageViewModel.kt @@ -0,0 +1,13 @@ +package xyz.apiote.bimba.czwek.dashboard.ui.voyage + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class VoyageViewModel : ViewModel() { + + private val _text = MutableLiveData<String>().apply { + value = "This is voyage Fragment" + } + val text: LiveData<String> = _text +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt new file mode 100644 index 0000000000000000000000000000000000000000..35f871c5fc86be2a7acbee7525b3d45496e97c2e --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt @@ -0,0 +1,266 @@ +package xyz.apiote.bimba.czwek.departures + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.content.res.Configuration.* +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.DepartureV1 +import xyz.apiote.bimba.czwek.api.VehicleV1 +import xyz.apiote.bimba.czwek.dpToPixelI +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.TilesOverlay +import org.osmdroid.views.overlay.gestures.RotationGestureOverlay +import java.util.* + + +class BimbaDepartureViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val root: View = itemView.findViewById(R.id.departure) + val lineIcon: ImageView = itemView.findViewById(R.id.line_icon) + val departureTime: TextView = itemView.findViewById(R.id.departure_time) + val lineName: TextView = itemView.findViewById(R.id.departure_line) + val headsign: TextView = itemView.findViewById(R.id.departure_headsign) + + companion object { + fun bind( + departure: DepartureV1, + holder: BimbaDepartureViewHolder?, + context: Context?, + onClickListener: (DepartureV1) -> Unit + ) { + holder?.root?.setOnClickListener { + onClickListener(departure) + } + holder?.lineIcon?.setImageBitmap(departure.vehicle.Line.icon(context!!)) + holder?.lineIcon?.contentDescription = departure.vehicle.Line.kind.name + holder?.lineName?.text = departure.vehicle.Line.name + holder?.headsign?.text = context?.getString(R.string.departure_headsign, departure.vehicle.Headsign) + holder?.headsign?.contentDescription = + context?.getString(R.string.departure_headsign_content_description, departure.vehicle.Headsign) + + holder?.departureTime?.text = departure.statusText(context) + } + } +} + +class BimbaDeparturesAdapter( + private val inflater: LayoutInflater, + private val context: Context?, + private var departures: List<DepartureV1>, + private val onClickListener: ((DepartureV1) -> Unit) +) : + RecyclerView.Adapter<BimbaDepartureViewHolder>() { + + private var departuresPositions: MutableMap<String, Int> = HashMap() + + init { + departures.forEachIndexed { i, departure -> + departuresPositions[departure.ID] = i + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaDepartureViewHolder { + val rowView = inflater.inflate(R.layout.departure, parent, false) + return BimbaDepartureViewHolder(rowView) + } + + override fun onBindViewHolder(holder: BimbaDepartureViewHolder, position: Int) { + BimbaDepartureViewHolder.bind(departures[position], holder, context, onClickListener) + } + + override fun getItemCount(): Int = departures.size + + fun get(ID: String): DepartureV1? { + val position = departuresPositions[ID] + return if (position == null) { + null + } else { + departures[position] + } + } + + @SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil + fun update(departures: List<DepartureV1>) { + val newPositions: MutableMap<String, Int> = HashMap() + departures.forEachIndexed { i, departure -> + newPositions[departure.ID] = i + } + + this.departures = departures + departuresPositions = newPositions + notifyDataSetChanged() + } +} + +class DepartureBottomSheet(private var departure: DepartureV1) : BottomSheetDialogFragment() { + companion object { + const val TAG = "DepartureBottomSheet" + } + + private var cancelCallback: (() -> Unit)? = null + + fun setOnCancel(callback: () -> Unit) { + cancelCallback = callback + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + cancelCallback?.let { it() } + } + + fun departureID(): String { + return departure.ID + } + + fun update(departure: DepartureV1) { + this.departure = departure + this.view?.let { context?.let { ctx -> setContent(it, ctx) } } + } + + private fun setContent(view: View, ctx: Context) { + view.apply { + findViewById<TextView>(R.id.time).text = departure.timeString(ctx) + + findViewById<ImageView>(R.id.rt_icon).apply { + visibility = if (departure.isRealtime) { + View.VISIBLE + } else { + View.GONE + } + } + findViewById<ImageView>(R.id.wheelchair_icon).apply { + visibility = if (departure.vehicle.let { + it.getCapability(VehicleV1.Capability.LOW_FLOOR) || it.getCapability(VehicleV1.Capability.LOW_ENTRY) || it.getCapability( + VehicleV1.Capability.RAMP + ) + }) { + View.VISIBLE + } else { + View.GONE + } + } + + findViewById<TextView>(R.id.line).apply { + contentDescription = getString( + R.string.vehicle_headsign_content_description, + departure.vehicle.Line.name, + departure.vehicle.Headsign + ) + text = getString(R.string.vehicle_headsign, departure.vehicle.Line.name, departure.vehicle.Headsign) + } + + findViewById<TextView>(R.id.boarding_text).text = departure.boardingText(ctx) + // todo units -- [3.1] settings or system-based + findViewById<TextView>(R.id.speed_text).text = + getString(R.string.speed_in_km_per_h, departure.vehicle.Speed * 3.6) + findViewById<TextView>(R.id.congestion_text).text = departure.vehicle.congestion(ctx) + findViewById<TextView>(R.id.occupancy_text).text = departure.vehicle.occupancy(ctx) + + findViewById<ImageView>(R.id.ac).visibility = + if (departure.vehicle.getCapability(VehicleV1.Capability.AC)) { + View.VISIBLE + } else { + View.GONE + } + findViewById<ImageView>(R.id.bike).visibility = + if (departure.vehicle.getCapability(VehicleV1.Capability.BIKE)) { + View.VISIBLE + } else { + View.GONE + } + findViewById<ImageView>(R.id.voice).visibility = + if (departure.vehicle.getCapability(VehicleV1.Capability.VOICE)) { + View.VISIBLE + } else { + View.GONE + } + findViewById<ImageView>(R.id.ticket).visibility = + if (departure.vehicle.let { + it.getCapability(VehicleV1.Capability.TICKET_DRIVER) || it.getCapability(VehicleV1.Capability.TICKET_MACHINE) + }) { + View.VISIBLE + } else { + View.GONE + } + findViewById<ImageView>(R.id.usb).visibility = + if (departure.vehicle.getCapability(VehicleV1.Capability.USB_CHARGING)) { + View.VISIBLE + } else { + View.GONE + } + findViewById<MapView>(R.id.map).let { map -> + if (departure.vehicle.Position.isZero()) { + map.visibility = View.GONE + return@let + } + map.controller.apply { // todo[3.1] glide to centre, not jump + setZoom(19.0f.toDouble()) + setCenter( + GeoPoint( + departure.vehicle.location().latitude, + departure.vehicle.location().longitude + ) + ) + } + + map.overlays.removeAll { marker -> + marker is Marker + } + val marker = Marker(map).apply { + position = + GeoPoint(departure.vehicle.location().latitude, departure.vehicle.location().longitude) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + icon = context?.let { ctx -> departure.vehicle.icon(ctx, 2f) } + setOnClickListener {} + } + map.overlays.add(marker) + map.invalidate() + } + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val content = inflater.inflate(R.layout.departure_bottom_sheet, container, false) + + context?.let { ctx -> + content.apply { + findViewById<MapView>(R.id.map).let { map -> + map.setTileSource(TileSourceFactory.MAPNIK) + if (((context?.resources?.configuration?.uiMode ?: UI_MODE_NIGHT_UNDEFINED) + and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES + ) { + map.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS) + } + map.zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + map.setOnTouchListener { _, _ -> true } + map.setMultiTouchControls(true) + map.overlays.add(RotationGestureOverlay(map).apply { isEnabled = true }) + } + + setContent(this, ctx) + + (dialog as BottomSheetDialog).behavior.peekHeight = dpToPixelI(180f) + + } + } + return content + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..a8813189a98bdebfaccbc5705bd3c0a1863d8049 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt @@ -0,0 +1,154 @@ +package xyz.apiote.bimba.czwek.departures + +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.WindowCompat +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.* +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.* +import xyz.apiote.bimba.czwek.databinding.ActivityDeparturesBinding + +class DeparturesActivity : AppCompatActivity() { + private var _binding: ActivityDeparturesBinding? = null + private val binding get() = _binding!! + + private lateinit var adapter: BimbaDeparturesAdapter + + private val handler = Handler(Looper.getMainLooper()) + private var runnable = Runnable {} + + private var openBottomSheet: DepartureBottomSheet? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + _binding = ActivityDeparturesBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.collapsingLayout.apply { + title = getName() + val tf = ResourcesCompat.getFont(this@DeparturesActivity, R.font.yellowcircle8) + setCollapsedTitleTypeface(tf) + setExpandedTitleTypeface(tf) + } + + binding.departuresRecycler.layoutManager = LinearLayoutManager(this) + adapter = BimbaDeparturesAdapter(layoutInflater, this, listOf()) { + DepartureBottomSheet(it).apply { + show(supportFragmentManager, DepartureBottomSheet.TAG) + openBottomSheet = this + setOnCancel { openBottomSheet = null } + } + } + binding.departuresRecycler.adapter = adapter + WindowCompat.setDecorFitsSystemWindows(window, false) + } + + override fun onResume() { + super.onResume() + getDepartures() + } + + override fun onPause() { + super.onPause() + handler.removeCallbacks(runnable) + } + + private fun getName(): String { + return when (intent?.action) { + Intent.ACTION_VIEW -> getCode() + null -> intent?.extras?.getString("name") ?: "" + else -> "" + } + } + + private fun getCode(): String { + @Suppress("SpellCheckingInspection") + return when (intent?.action) { + Intent.ACTION_VIEW -> intent?.data?.getQueryParameter("przystanek") ?: "" + null -> intent?.extras?.getString("code") ?: "" + else -> "" + } + } + + private fun getDepartures() { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + MainScope().launch { + val result = getDepartures( + cm, + Server.get(this@DeparturesActivity), getCode() + ) + + if (result.error != null) { + showError(result.error) + if (result.stream != null) { + val response = ErrorResponse.unmarshal(result.stream) + Log.w("Departures", "${result.error.statusCode}, ${response.message}") + } else { + Log.w( + "Departures", + "${result.error.statusCode}, ${getString(result.error.stringResource)}" + ) + } + return@launch + } + val (departures, stop) = when (val response = DeparturesResponse.unmarshal(result.stream!!)) { + is DeparturesResponseDev -> Pair(response.departures, response.stop) + is DeparturesResponseV1 -> Pair(response.departures, response.stop) + else -> Pair(null, null) + } + updateItems(departures!!, stop!!) + openBottomSheet?.departureID()?.let { adapter.get(it) }?.let { openBottomSheet?.update(it) } + } + handler.removeCallbacks(runnable) + runnable = Runnable { getDepartures() } + handler.postDelayed(runnable, 30 * 1000) + } + + private fun showError(error: Error) { + binding.departuresProgress.visibility = View.GONE + binding.departuresRecycler.visibility = View.GONE + binding.errorImage.visibility = View.VISIBLE + binding.errorText.visibility = View.VISIBLE + + binding.errorText.text = getString(error.stringResource) + binding.errorImage.setImageDrawable(AppCompatResources.getDrawable(this, error.imageResource)) + } + + private fun updateItems(departures: List<DepartureV1>, stop: StopV1) { + binding.departuresProgress.visibility = View.GONE + adapter.update(departures) + binding.collapsingLayout.apply { + title = stop.name + } + if (departures.isEmpty()) { + binding.errorImage.visibility = View.VISIBLE + binding.errorText.visibility = View.VISIBLE + binding.departuresRecycler.visibility = View.GONE + + binding.errorText.text = getString(R.string.no_departures) + binding.errorImage.setImageDrawable( + AppCompatResources.getDrawable( + this, + R.drawable.error_search + ) + ) + } else { + binding.departuresOverlay.visibility = View.GONE + binding.errorImage.visibility = View.GONE + binding.errorText.visibility = View.GONE + binding.departuresRecycler.visibility = View.VISIBLE + } + // todo [3.1] alerts + // todo [3.1] stop info + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..0b82651e9a1acf4f7bfa8a20b80a4aae3fcda946 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt @@ -0,0 +1,23 @@ +package xyz.apiote.bimba.czwek.onboarding + +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import xyz.apiote.bimba.czwek.dashboard.MainActivity + +class FirstRunActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + + val preferences = getSharedPreferences("shp", MODE_PRIVATE) + val intent = if (preferences.getBoolean("firstRun", true)) { + Intent(this, OnboardingActivity::class.java) + } else { + Intent(this, MainActivity::class.java) + } + startActivity(intent) + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..4d5f9e20752b529f3885d9155f26630118633c94 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt @@ -0,0 +1,73 @@ +package xyz.apiote.bimba.czwek.onboarding + +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.RelativeSizeSpan +import android.text.style.StyleSpan +import android.widget.Button +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.databinding.ActivityOnboardingBinding +import xyz.apiote.bimba.czwek.settings.ServerChooserActivity + +class OnboardingActivity : AppCompatActivity() { + private var _binding: ActivityOnboardingBinding? = null + private val binding get() = _binding!! + + private val activityLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (!getSharedPreferences("shp", MODE_PRIVATE).getBoolean("firstRun", true)) { + finish() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + _binding = ActivityOnboardingBinding.inflate(layoutInflater) + setContentView(binding.root) + + + prepareButton( + binding.buttonSimple, + getString(R.string.onboarding_simple), + getString(R.string.onboarding_simple_action), + true + ) + prepareButton( + binding.buttonAdvanced, + getString(R.string.onboarding_advanced), + getString(R.string.onboarding_advanced_action), + false + ) + } + + private fun prepareButton(button: Button, title: String, description: String, simple: Boolean) { + button.text = SpannableStringBuilder().apply { + append( + title, + StyleSpan(Typeface.BOLD), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + append("\n") + append( + description, + RelativeSizeSpan(.75f), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + button.setOnClickListener { + moveOn(simple) + } + } + + private fun moveOn(simple: Boolean) { + val intent = Intent(this, ServerChooserActivity::class.java).apply { + putExtra("simple", simple) + } + activityLauncher.launch(intent) + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt new file mode 100644 index 0000000000000000000000000000000000000000..81cc5bbe9e2f9f1759cfbb9c76c7c70a185049b4 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt @@ -0,0 +1,133 @@ +package xyz.apiote.bimba.czwek.search + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.mancj.materialsearchbar.adapter.SuggestionsAdapter +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.Line +import xyz.apiote.bimba.czwek.api.QueryableV1 +import xyz.apiote.bimba.czwek.api.StopV1 + +class BimbaViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val root: View = itemView.findViewById(R.id.suggestion) + val icon: ImageView = itemView.findViewById(R.id.suggestion_image) + val title: TextView = itemView.findViewById(R.id.suggestion_title) + val description: TextView = itemView.findViewById(R.id.suggestion_description) + + companion object { + fun bind( + queryable: QueryableV1, + holder: BimbaViewHolder?, + context: Context?, + onClickListener: (QueryableV1) -> Unit + ) { + when (queryable) { + is StopV1 -> bindStop(queryable, holder, context) + //is Line -> bindLine(queryable, holder, context) + } + holder?.root?.setOnClickListener { + onClickListener(queryable) + } + } + + private fun bindStop(stop: StopV1, holder: BimbaViewHolder?, context: Context?) { + holder?.icon?.apply { + setImageDrawable(stop.icon(context!!)) + contentDescription = context.getString(R.string.stop_content_description) + } + holder?.title?.text = context?.getString(R.string.stop_title, stop.name, stop.code) + context?.let { + stop.changeOptions(it).let { changeOptions -> + holder?.description?.apply { + text = changeOptions.first + contentDescription = changeOptions.second + } + } + } + } + + private fun bindLine(line: Line, holder: BimbaViewHolder?, context: Context?) { + holder?.icon?.apply { + setImageBitmap(line.icon(context!!)) + contentDescription = line.type.name + colorFilter = null + } + holder?.title?.text = line.name + holder?.description?.text = context?.getString( + R.string.line_headsigns, + line.headsignsThere.joinToString { it }, + line.headsignsBack.joinToString { it }) + holder?.description?.contentDescription = context?.getString( + R.string.line_headsigns_content_description, + line.headsignsThere.joinToString { it }, + line.headsignsBack.joinToString { it }) + } + } +} + +interface Adapter { + fun createViewHolder( + inflater: LayoutInflater, + layout: Int, + parent: ViewGroup + ): BimbaViewHolder { + val rowView = inflater.inflate(layout, parent, false) + return BimbaViewHolder(rowView) + } + + fun bindSuggestionHolder( + queryable: QueryableV1, + holder: BimbaViewHolder?, + context: Context?, + onClickListener: (QueryableV1) -> Unit + ) { + BimbaViewHolder.bind(queryable, holder, context, onClickListener) + } +} + +class BimbaResultsAdapter( + private val inflater: LayoutInflater, + private val context: Context?, + private var queryables: List<QueryableV1>, + private val onClickListener: ((QueryableV1) -> Unit) +) : + RecyclerView.Adapter<BimbaViewHolder>(), Adapter { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaViewHolder { + return createViewHolder(inflater, R.layout.result, parent) + } + + override fun onBindViewHolder(holder: BimbaViewHolder, position: Int) { + bindSuggestionHolder(queryables[position], holder, context, onClickListener) + } + + override fun getItemCount(): Int = queryables.size + + @SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil + fun update(queryables: List<QueryableV1>) { + this.queryables = queryables + notifyDataSetChanged() + } +} + +class BimbaSuggestionsAdapter( + inflater: LayoutInflater, + private val context: Context?, + private val onClickListener: ((QueryableV1) -> Unit) +) : + SuggestionsAdapter<QueryableV1, BimbaViewHolder>(inflater), Adapter { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaViewHolder { + return createViewHolder(layoutInflater, R.layout.suggestion, parent) + } + + override fun getSingleViewHeight(): Int = 72 + + override fun onBindSuggestionHolder(queryable: QueryableV1, holder: BimbaViewHolder?, pos: Int) { + bindSuggestionHolder(queryable, holder, context, onClickListener) + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..0bb34efb22825f913e597157e6d40d025455559b --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt @@ -0,0 +1,198 @@ +package xyz.apiote.bimba.czwek.search + +import android.content.Context +import android.content.Intent +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.net.ConnectivityManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.view.WindowCompat +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.* +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.* +import xyz.apiote.bimba.czwek.databinding.ActivityResultsBinding +import xyz.apiote.bimba.czwek.departures.DeparturesActivity +import java.io.InputStream + +class ResultsActivity : AppCompatActivity(), LocationListener { + enum class Mode { + MODE_LOCATION, MODE_SEARCH + } + + private var _binding: ActivityResultsBinding? = null + private val binding get() = _binding!! + + private lateinit var adapter: BimbaResultsAdapter + + private val handler = Handler(Looper.getMainLooper()) + private var runnable = Runnable {} + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + _binding = ActivityResultsBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.resultsRecycler.layoutManager = LinearLayoutManager(this) + adapter = BimbaResultsAdapter(layoutInflater, this, listOf()) { + when (it) { + is StopV1 -> { + val intent = Intent(this, DeparturesActivity::class.java).apply { + putExtra("code", it.code) + putExtra("name", it.name) + } + startActivity(intent) + } + is Line -> { + TODO("[3.1] start line graph activity") + } + } + } + binding.resultsRecycler.adapter = adapter + setSupportActionBar(binding.topAppBar) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + @Suppress("DEPRECATION") // fixme later getSerializable in API>=33 + when (intent.extras?.get("mode")) { + Mode.MODE_LOCATION -> { + supportActionBar?.title = getString(R.string.stops_nearby) + locate() + } + Mode.MODE_SEARCH -> { + val query = intent.extras?.getString("query")!! + supportActionBar?.title = getString(R.string.results_for, query) + getQueryablesByQuery(Server.get(this), query) + } + } + } + + private fun locate() { + try { + val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, 1000 * 60 * 10, 100f, this + ) + handler.removeCallbacks(runnable) + runnable = Runnable { + showError(Error(0, R.string.error_gps, R.drawable.error_gps)) + } + handler.postDelayed(runnable, 60 * 1000) + locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + ?.let { onLocationChanged(it) } + } catch (_: SecurityException) { + // this won’t happen because we don’t start this activity without location permission + } + } + + override fun onLocationChanged(location: Location) { + handler.removeCallbacks(runnable) + getQueryablesByLocation(Server.get(this), PositionV1(location.latitude, location.longitude)) + } + + override fun onResume() { + super.onResume() + @Suppress("DEPRECATION") // fixme later getSerializable in API>=33 + if (intent.extras?.get("mode") == Mode.MODE_LOCATION) { + locate() + } + } + + override fun onPause() { + super.onPause() + val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager + locationManager.removeUpdates(this) + handler.removeCallbacks(runnable) + } + + override fun onDestroy() { + super.onDestroy() + val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager + locationManager.removeUpdates(this) + handler.removeCallbacks(runnable) + } + + private fun getQueryablesByQuery(server: Server, query: String) { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + MainScope().launch { + val result = queryQueryables(cm, server, query) + if (result.error != null) { + if (result.stream != null) { + val response = ErrorResponse.unmarshal(result.stream) + Log.w("Results", "${result.error.statusCode}, ${response.message}") + } else { + Log.w( + "Results", + "${result.error.statusCode}, ${getString(result.error.stringResource)}" + ) + } + showError(result.error) + } else { + updateItems(unmarshallQueryablesResponse(result.stream!!)!!) + } + } + } + + private fun getQueryablesByLocation(server: Server, position: PositionV1) { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + MainScope().launch { + val result = locateQueryables(cm, server, position) + if (result.error != null) { + Log.e("Results.location", "$result") + showError(result.error) + } else { + updateItems(unmarshallQueryablesResponse(result.stream!!)!!) + } + } + } + + private fun showError(error: Error) { + binding.resultsProgress.visibility = View.GONE + binding.resultsRecycler.visibility = View.GONE + binding.errorImage.visibility = View.VISIBLE + binding.errorText.visibility = View.VISIBLE + + binding.errorText.text = getString(error.stringResource) + binding.errorImage.setImageDrawable(AppCompatResources.getDrawable(this, error.imageResource)) + } + + private fun updateItems(queryables: List<QueryableV1>) { + binding.resultsProgress.visibility = View.GONE + adapter.update(queryables) + if (queryables.isEmpty()) { + binding.errorImage.visibility = View.VISIBLE + binding.errorText.visibility = View.VISIBLE + binding.resultsRecycler.visibility = View.GONE + + binding.errorText.text = getString(R.string.error_404) + binding.errorImage.setImageDrawable( + AppCompatResources.getDrawable( + this, + R.drawable.error_search + ) + ) + } else { + binding.resultsOverlay.visibility = View.GONE + binding.errorImage.visibility = View.GONE + binding.errorText.visibility = View.GONE + binding.resultsRecycler.visibility = View.VISIBLE + } + } + + private suspend fun unmarshallQueryablesResponse(stream: InputStream): List<QueryableV1>? { + return withContext(Dispatchers.IO) { + when (val response = QueryablesResponse.unmarshal(stream)) { + is QueryablesResponseDev -> response.queryables + is QueryablesResponseV1 -> response.queryables + else -> null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..80c0975d04174d0958a97d6e5f3f03342c3ea17f --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt @@ -0,0 +1,155 @@ +package xyz.apiote.bimba.czwek.settings + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.ConnectivityManager +import android.os.Bundle +import android.util.Log +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.edit +import androidx.core.widget.addTextChangedListener +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yaml.snakeyaml.error.YAMLException +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.Bimba +import xyz.apiote.bimba.czwek.api.Server +import xyz.apiote.bimba.czwek.api.TrafficFormatException +import xyz.apiote.bimba.czwek.api.getBimba +import xyz.apiote.bimba.czwek.databinding.ActivityServerChooserBinding +import xyz.apiote.bimba.czwek.settings.feeds.FeedChooserActivity + +class ServerChooserActivity : AppCompatActivity() { + private var _binding: ActivityServerChooserBinding? = null + private val binding get() = _binding!! + + private val activityLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (!preferences.getBoolean("inFeedsTransaction", true)) { + finish() + } + } + + private lateinit var preferences: SharedPreferences + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + preferences = getSharedPreferences("shp", MODE_PRIVATE) + + if (intent.getBooleanExtra("simple", false)) { + setServer("bimba.apiote.xyz", "") + checkServer(true) + } else { + + _binding = ActivityServerChooserBinding.inflate(layoutInflater) + setContentView(binding.root) + + preferences.edit(true) { + putBoolean("inFeedsTransaction", true) + } + + binding.button.isEnabled = false + binding.serverField.editText!!.addTextChangedListener { editable -> + binding.button.isEnabled = !editable.isNullOrBlank() + } + + if (!preferences.getBoolean("firstRun", true)) { + Server.get(this).let { server -> + binding.serverField.editText!!.setText(server.host) + binding.tokenField.editText!!.setText(server.token) + } + } + + binding.button.setOnClickListener { + setServer( + binding.serverField.editText!!.text.toString(), + binding.tokenField.editText!!.text.toString() + ) + checkServer(false) + } + } + } + + private fun showDialog( + title: Int, description: Int, icon: Int, onPositive: (() -> Unit)? + ) { + MaterialAlertDialogBuilder(this).setIcon(AppCompatResources.getDrawable(this, icon)) + .setTitle(getString(title)).setMessage(getString(description)) + .setNegativeButton(resources.getString(R.string.cancel)) { _, _ -> }.apply { + if (onPositive != null) { + setPositiveButton(resources.getString(R.string.cont)) { _, _ -> + onPositive() + } + } + }.show() + } + + private fun checkServer(isSimple: Boolean) { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + MainScope().launch { + val result = getBimba(cm, Server.get(this@ServerChooserActivity)) + if (result.error != null) { + showDialog(R.string.error, result.error.stringResource, result.error.imageResource, null) + Log.w( + "ServerChooser", "${result.error.statusCode}, ${getString(result.error.stringResource)}" + ) + return@launch + } + val bimba = try { + withContext(Dispatchers.IO) { + Bimba.unmarshal(result.stream!!) + } + } catch (e: YAMLException) { + Log.w("ServerChooser", e.message ?: "YAML error") + showDialog(R.string.error, R.string.error_traffic_spec, R.drawable.error_server, null) + return@launch + } catch (e: TrafficFormatException) { + Log.w("ServerChooser", e.message) + showDialog(R.string.error, R.string.error_traffic_spec, R.drawable.error_server, null) + return@launch + } + val token = preferences.getString("token", "") + updateServer(bimba.servers[0]["url"]!!) + + if (bimba.isPrivate() && token == "") { + showDialog(R.string.error, R.string.server_private_question, R.drawable.error_sec, null) + return@launch + } + if (bimba.isRateLimited() && token == "" && !isSimple) { + showDialog( + R.string.rate_limit, R.string.server_rate_limited_question, R.drawable.error_limit + ) { + runFeedsActivity() + } + return@launch + } + runFeedsActivity() + } + } + + private fun setServer(hostname: String, token: String) { + preferences.edit(true) { + putString("host", hostname) + putString("token", token) + } + } + + private fun updateServer(apiPath: String) { + preferences.edit(true) { + putString("apiPath", apiPath) + } + } + + private fun runFeedsActivity() { + activityLauncher.launch(Intent(this, FeedChooserActivity::class.java)) + if (intent.getBooleanExtra("simple", false)) { + finish() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..c7ee64b4a4e8c7615ec942ab0725ade256e0551f --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt @@ -0,0 +1,108 @@ +package xyz.apiote.bimba.czwek.settings.feeds + +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.edit +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import xyz.apiote.bimba.czwek.api.* +import xyz.apiote.bimba.czwek.dashboard.MainActivity +import xyz.apiote.bimba.czwek.databinding.ActivityFeedChooserBinding + +class FeedChooserActivity : AppCompatActivity() { + private var _binding: ActivityFeedChooserBinding? = null + private val binding get() = _binding!! + + private lateinit var adapter: BimbaFeedInfoAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + _binding = ActivityFeedChooserBinding.inflate(layoutInflater) + setContentView(binding.root) + + setUpRecycler() + getServer() + + binding.button.setOnClickListener { + moveOn() + } + } + + private fun setUpRecycler() { + binding.resultsRecycler.layoutManager = LinearLayoutManager(this) + adapter = BimbaFeedInfoAdapter(layoutInflater, listOf(), this) { + FeedBottomSheet(it).show(supportFragmentManager, FeedBottomSheet.TAG) + } + binding.resultsRecycler.adapter = adapter + } + + private fun getServer() { + binding.progress.visibility = View.VISIBLE + binding.resultsRecycler.visibility = View.GONE + + MainScope().launch { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + Log.i("FeedChooser", "${Server.get(this@FeedChooserActivity)}") + val result = getFeeds(cm, Server.get(this@FeedChooserActivity)) + if (result.error != null) { + showError(result.error.imageResource, result.error.stringResource) + if (result.stream != null) { + val response = ErrorResponse.unmarshal(result.stream) + Log.w("FeedChooser", "${result.error.statusCode}, ${response.message}") + } else { + Log.w( + "FeedChooser", + "${result.error.statusCode}, ${getString(result.error.stringResource)}" + ) + } + return@launch + } + val feeds = when (val response = FeedsResponse.unmarshal(result.stream!!)) { + is FeedsResponseDev -> response.feeds + is FeedsResponseV1 -> response.feeds + else -> null + } + updateItems(feeds!!) + } + } + + private fun moveOn() { + val preferences = getSharedPreferences("shp", MODE_PRIVATE) + preferences.edit(true) { + putBoolean("inFeedsTransaction", false) + } + if (preferences.getBoolean("firstRun", true)) { + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + } + finish() + } + + private fun showError(image: Int, text: Int) { + binding.progress.visibility = View.GONE + binding.resultsRecycler.visibility = View.GONE + binding.errorImage.apply { + visibility = View.VISIBLE + setImageDrawable(AppCompatResources.getDrawable(this@FeedChooserActivity, image)) + } + binding.errorText.apply { + visibility = View.VISIBLE + setText(text) + } + + } + + private fun updateItems(feeds: List<FeedInfoV1>) { + binding.feedsOverlay.visibility = View.GONE + binding.resultsRecycler.visibility = View.VISIBLE + binding.button.visibility = View.VISIBLE + adapter.update(feeds) + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedInfos.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedInfos.kt new file mode 100644 index 0000000000000000000000000000000000000000..ff9d63e7b907ebbdfca83abc145d1feb02bda4aa --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedInfos.kt @@ -0,0 +1,102 @@ +package xyz.apiote.bimba.czwek.settings.feeds + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.edit +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.materialswitch.MaterialSwitch +import xyz.apiote.bimba.czwek.R +import xyz.apiote.bimba.czwek.api.FeedInfoV1 + + +class BimbaFeedInfoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val root: View = itemView.findViewById(R.id.feed) + val switch: MaterialSwitch = itemView.findViewById(R.id.feed_switch) + val name: TextView = itemView.findViewById(R.id.feed_name) + + companion object { + fun bind( + feed: FeedInfoV1, + context: Context, + holder: BimbaFeedInfoViewHolder?, + onClickListener: (FeedInfoV1) -> Unit + ) { + val shp = context.getSharedPreferences("shp", MODE_PRIVATE) + val host = shp.getString("host", "bimba.apiote.xyz")!! + val enabledFeeds = + shp.getString("${host}_feeds", "")!!.split(",").associateWith { }.toMutableMap() + + holder?.root?.setOnClickListener { + onClickListener(feed) + } + holder?.name?.text = feed.name + holder?.switch?.apply { + isChecked = feed.id in enabledFeeds + setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + enabledFeeds[feed.id] = Unit + } else { + enabledFeeds.remove(feed.id) + } + shp.edit(true) { + putString( + "${host}_feeds", + enabledFeeds.map { it.key }.filter { it != "" }.joinToString(separator = ",") + ) + } + } + } + } + } +} + +class BimbaFeedInfoAdapter( + private val inflater: LayoutInflater, + private var feeds: List<FeedInfoV1>, + private val context: Context, + private val onClickListener: ((FeedInfoV1) -> Unit) +) : + RecyclerView.Adapter<BimbaFeedInfoViewHolder>() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaFeedInfoViewHolder { + val rowView = inflater.inflate(R.layout.feedinfo, parent, false) + return BimbaFeedInfoViewHolder(rowView) + } + + override fun onBindViewHolder(holder: BimbaFeedInfoViewHolder, position: Int) { + BimbaFeedInfoViewHolder.bind(feeds[position], context, holder, onClickListener) + } + + override fun getItemCount(): Int = feeds.size + + @SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil + fun update(items: List<FeedInfoV1>) { + feeds = items + notifyDataSetChanged() + } +} + +class FeedBottomSheet(private var feed: FeedInfoV1) : BottomSheetDialogFragment() { + companion object { + const val TAG = "DepartureBottomSheet" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val content = inflater.inflate(R.layout.feed_bottom_sheet, container, false) + content.findViewById<TextView>(R.id.title).text = feed.name + content.findViewById<TextView>(R.id.description).text = feed.description + content.findViewById<TextView>(R.id.attribution).text = feed.attribution + content.findViewById<TextView>(R.id.update_time).text = feed.formatDate() + return content + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/utils.kt b/app/src/main/java/xyz/apiote/bimba/czwek/utils.kt new file mode 100644 index 0000000000000000000000000000000000000000..a5afaa2cdb25fd6504138df76cd957a7bac1b73d --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/utils.kt @@ -0,0 +1,12 @@ +package xyz.apiote.bimba.czwek + +import android.content.res.Resources +import android.util.DisplayMetrics +import kotlin.math.roundToInt + +fun dpToPixel(dp: Float): Float { + val metrics: DisplayMetrics = Resources.getSystem().displayMetrics + return dp * (metrics.densityDpi / 160f) +} + +fun dpToPixelI(dp: Float): Int = dpToPixel(dp).roundToInt() \ No newline at end of file diff --git a/app/src/main/res/layout/activity_departures.xml b/app/src/main/res/layout/activity_departures.xml index 829df58e2ab1a1f337289fe70254abeca25b9bc2..328035a5a820483133b82c8089449684124bd5ac 100644 --- a/app/src/main/res/layout/activity_departures.xml +++ b/app/src/main/res/layout/activity_departures.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="16dp"> @@ -30,8 +30,8 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:ignore="ContentDescription" - tools:src="@drawable/error_net" /> + tool:ignore="ContentDescription" + tool:src="@drawable/error_net" /> <com.google.android.material.textview.MaterialTextView android:id="@+id/error_text" @@ -46,7 +46,7 @@ android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/error_image" - tools:text="No connection" /> + tool:text="No connection" /> </androidx.constraintlayout.widget.ConstraintLayout> <com.google.android.material.appbar.AppBarLayout diff --git a/app/src/main/res/layout/activity_feed_chooser.xml b/app/src/main/res/layout/activity_feed_chooser.xml index 7a6ba5a4d211a0a4e4760be337cd55b20e930879..2995a2b237ba74aa7f1cfc7055c7b641820fbd13 100644 --- a/app/src/main/res/layout/activity_feed_chooser.xml +++ b/app/src/main/res/layout/activity_feed_chooser.xml @@ -1,21 +1,53 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".settings.feeds.FeedChooserActivity"> + tool:context="xyz.apiote.bimba.czwek.settings.feeds.FeedChooserActivity"> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/feeds_overlay" + android:layout_width="match_parent" + android:layout_height="match_parent"> - <com.google.android.material.progressindicator.CircularProgressIndicator - android:id="@+id/progress" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:indeterminate="true" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> + <com.google.android.material.progressindicator.CircularProgressIndicator + android:id="@+id/progress" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:indeterminate="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <ImageView + android:id="@+id/error_image" + android:layout_width="92dp" + android:layout_height="92dp" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tool:ignore="ContentDescription" + tool:src="@drawable/error_net" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/error_text" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="16dp" + android:textAlignment="center" + android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall" + android:visibility="gone" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/error_image" + tool:text="No connection" /> + </androidx.constraintlayout.widget.ConstraintLayout> <androidx.recyclerview.widget.RecyclerView android:id="@+id/results_recycler" @@ -25,6 +57,7 @@ android:layout_marginStart="8dp" android:layout_marginTop="8dp" android:layout_marginEnd="8dp" android:layout_marginBottom="8dp" + android:visibility="gone" app:layout_behavior="@string/appbar_scrolling_view_behavior" app:layout_constraintBottom_toTopOf="@+id/button" app:layout_constraintEnd_toEndOf="parent" @@ -38,6 +71,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginBottom="16dp" android:text="@string/save" + android:visibility="gone" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml index 15b97bbb6b05d7328e0d71068d0d5c6fb412d81d..657b251858872a2863f33e5ed13e3a86eaff5d88 100644 --- a/app/src/main/res/layout/activity_onboarding.xml +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -1,10 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".onboarding.OnboardingActivity"> + tool:context="xyz.apiote.bimba.czwek.onboarding.OnboardingActivity"> <androidx.constraintlayout.widget.Guideline android:id="@+id/guideline2" diff --git a/app/src/main/res/layout/activity_results.xml b/app/src/main/res/layout/activity_results.xml index 076c6fa151bbfbd5e960b8229594a51c76aa13ef..5a4a9eaf2035a901b8502d56a822ae2be4853f4d 100644 --- a/app/src/main/res/layout/activity_results.xml +++ b/app/src/main/res/layout/activity_results.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="16dp"> @@ -30,8 +30,8 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:ignore="ContentDescription" - tools:src="@drawable/error_net" /> + tool:ignore="ContentDescription" + tool:src="@drawable/error_net" /> <com.google.android.material.textview.MaterialTextView android:id="@+id/error_text" @@ -46,7 +46,7 @@ android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/error_image" - tools:text="No connection" /> + tool:text="No connection" /> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/activity_server_chooser.xml b/app/src/main/res/layout/activity_server_chooser.xml index 125c84dbbf76f24c4616ccd63ffd235340216fb6..e441ede9d9dcddf5c313710d0470c9b4bc936e0f 100644 --- a/app/src/main/res/layout/activity_server_chooser.xml +++ b/app/src/main/res/layout/activity_server_chooser.xml @@ -1,10 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".settings.ServerChooserActivity"> + tool:context="xyz.apiote.bimba.czwek.settings.ServerChooserActivity"> <com.google.android.material.textfield.TextInputLayout android:id="@+id/server_field" @@ -20,7 +20,8 @@ app:layout_constraintTop_toTopOf="parent"> <com.google.android.material.textfield.TextInputEditText android:layout_width="match_parent" - android:layout_height="wrap_content" /> + android:layout_height="wrap_content" + android:inputType="textUri" /> </com.google.android.material.textfield.TextInputLayout> diff --git a/app/src/main/res/layout/departure.xml b/app/src/main/res/layout/departure.xml index 429c5f76ca00a8df572208bce0977da615e9beb9..dc95cff290d969279967a19552d8e2a10722323e 100644 --- a/app/src/main/res/layout/departure.xml +++ b/app/src/main/res/layout/departure.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:id="@+id/departure" android:layout_width="match_parent" android:layout_height="wrap_content"> @@ -14,8 +14,8 @@ android:layout_marginStart="8dp" app:layout_constraintBottom_toTopOf="@+id/departure_headsign" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@+id/departure_time" - tools:srcCompat="@drawable/bus_black" - tools:ignore="ContentDescription" /> + tool:srcCompat="@drawable/bus_black" + tool:ignore="ContentDescription" /> <com.google.android.material.textview.MaterialTextView android:id="@+id/departure_time" @@ -26,7 +26,7 @@ android:layout_marginEnd="8dp" android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:text="1hr" /> + tool:text="1hr" /> <com.google.android.material.textview.MaterialTextView android:id="@+id/departure_line" @@ -37,7 +37,7 @@ android:layout_marginTop="8dp" android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall" app:layout_constraintStart_toEndOf="@+id/line_icon" app:layout_constraintTop_toTopOf="parent" - tools:text="Metropolitan" /> + tool:text="Metropolitan" /> <com.google.android.material.textview.MaterialTextView android:id="@+id/departure_headsign" @@ -46,7 +46,7 @@ android:layout_height="wrap_content" android:textAppearance="@style/TextAppearance.Material3.BodySmall" app:layout_constraintStart_toStartOf="@+id/departure_line" app:layout_constraintTop_toBottomOf="@+id/departure_line" - tools:text="» Tower Hill" /> + tool:text="» Tower Hill" /> </androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 0b6fc8cd6f610ad649bae777ea281aa8f230a660..6ace86db683233a7d0443fe2e126f2cb19cbe25b 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -1,11 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:tag="@string/title_home" - tools:context=".dashboard.ui.home.HomeFragment"> + tool:context="xyz.apiote.bimba.czwek.dashboard.ui.home.HomeFragment"> <com.mancj.materialsearchbar.MaterialSearchBar android:id="@+id/search_bar" @@ -47,6 +47,6 @@ app:layout_constraintBottom_toTopOf="@+id/floating_action_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/search_bar" - tools:ignore="ContentDescription" /> + tool:ignore="ContentDescription" /> </androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 84500fce485f296b22515dee3ce782aad7ef0efb..a7e78704ab09536045868577128dfd17f0323a63 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -1,10 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".dashboard.ui.map.MapFragment"> + tool:context="xyz.apiote.bimba.czwek.dashboard.ui.map.MapFragment"> <org.osmdroid.views.MapView android:id="@+id/map" diff --git a/app/src/main/res/layout/fragment_voyage.xml b/app/src/main/res/layout/fragment_voyage.xml index 977deb77d46bbd2f91cfe3fa362b466366f6a400..2f62cca177204072a86e1efc21078da574c0aded 100644 --- a/app/src/main/res/layout/fragment_voyage.xml +++ b/app/src/main/res/layout/fragment_voyage.xml @@ -1,10 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".dashboard.ui.voyage.VoyageFragment"> + tool:context="xyz.apiote.bimba.czwek.dashboard.ui.voyage.VoyageFragment"> <com.google.android.material.textview.MaterialTextView android:id="@+id/text_dashboard" diff --git a/app/src/main/res/layout/result.xml b/app/src/main/res/layout/result.xml index 639d48dc3683b3d9ccc6de9b63e669fce00a6fd5..01fcf7e803bb9022ef16c39446fc50f1d5814e0c 100644 --- a/app/src/main/res/layout/result.xml +++ b/app/src/main/res/layout/result.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:id="@+id/suggestion" android:layout_width="match_parent" android:layout_height="wrap_content"> @@ -16,7 +16,7 @@ android:layout_marginBottom="8dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:ignore="ContentDescription" /> + tool:ignore="ContentDescription" /> <com.google.android.material.textview.MaterialTextView android:id="@+id/suggestion_title" diff --git a/app/src/main/res/layout/suggestion.xml b/app/src/main/res/layout/suggestion.xml index 7ad3e5ab507b2259036ec8cd4fc2ba2de2e0d8f4..1d681d33fae944bf52f8243a589aab9b1aefdcce 100644 --- a/app/src/main/res/layout/suggestion.xml +++ b/app/src/main/res/layout/suggestion.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:id="@+id/suggestion" android:layout_width="match_parent" android:layout_height="72dp"> @@ -16,7 +16,7 @@ android:layout_marginBottom="8dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:ignore="ContentDescription" /> + tool:ignore="ContentDescription" /> <com.google.android.material.textview.MaterialTextView android:id="@+id/suggestion_title" diff --git a/app/src/main/res/navigation/front_navigation.xml b/app/src/main/res/navigation/front_navigation.xml index dc19c6dec415f9646646d2413ec7d19cf1d015d9..7dd5662bedc71ec157a816703d9b2402048f5186 100644 --- a/app/src/main/res/navigation/front_navigation.xml +++ b/app/src/main/res/navigation/front_navigation.xml @@ -1,25 +1,25 @@ <?xml version="1.0" encoding="utf-8"?> <navigation xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" + xmlns:tool="http://schemas.android.com/tools" android:id="@+id/front_navigation" app:startDestination="@+id/navigation_home"> <fragment android:id="@+id/navigation_home" - android:name="ml.adamsprogs.bimba.dashboard.ui.home.HomeFragment" + android:name="xyz.apiote.bimba.czwek.dashboard.ui.home.HomeFragment" android:label="@string/title_home" - tools:layout="@layout/fragment_home" /> + tool:layout="@layout/fragment_home" /> <fragment android:id="@+id/navigation_map" - android:name="ml.adamsprogs.bimba.dashboard.ui.map.MapFragment" + android:name="xyz.apiote.bimba.czwek.dashboard.ui.map.MapFragment" android:label="@string/title_map" - tools:layout="@layout/fragment_map" /> + tool:layout="@layout/fragment_map" /> <fragment android:id="@+id/navigation_voyage" - android:name="ml.adamsprogs.bimba.dashboard.ui.voyage.VoyageFragment" + android:name="xyz.apiote.bimba.czwek.dashboard.ui.voyage.VoyageFragment" android:label="@string/title_voyage" - tools:layout="@layout/fragment_voyage" /> + tool:layout="@layout/fragment_voyage" /> </navigation> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d45d164004ed6874ade857eac4f9e76cabfed86c..f32859adeab279291dc0da8294a9124ae7224571 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,7 +75,7 @@ Advanced <string name="onboarding_advanced_action">choose server</string> <string name="cancel">Cancel</string> <string name="error">Error</string> - <string name="rate_limit">Server is rate-limited</string> + <string name="rate_limit">Rate limit</string> <string name="server_rate_limited_question">This server is rate-limited and no token was given. Do you want to continue?</string> <string name="server_private_question">This server is private and no token was given</string> <string name="last_update">Last update: %s</string> @@ -83,4 +83,5 @@Feeds <string name="title_servers">Servers</string> <string name="title_cities">Cities</string> <string name="error_url">Malformed URL provided</string> + <string name="error_traffic_spec">Cannot verify traffic server</string> </resources> \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 25709369846ff777de7e4125525aaa5f972d6bfc..dc24f789c068396350617f6166ef162d6478a0bc 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,4 +1,4 @@ -<resources xmlns:tools="http://schemas.android.com/tools"> +<resources xmlns:tool="http://schemas.android.com/tools"> <style name="Theme.Bimba" parent="Theme.Material3.Light.NoActionBar"> <item name="android:fontFamily">@font/yellowcircle8</item> @@ -31,7 +31,7 @@- @color/md_theme_light_inversePrimary
<item name="statusBarBackground">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item> - <item name="android:enforceStatusBarContrast" tools:targetApi="q">false</item> + <item name="android:enforceStatusBarContrast" tool:targetApi="q">false</item> <item name="lightStatusBar">true</item> </style> diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 70b72709083414196241a0b63175b02d0b4e02ec..64131505cc055ae8432e5e6f350e280e352cd614 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,4 +1,4 @@ -<resources xmlns:tools="http://schemas.android.com/tools"> +<resources xmlns:tool="http://schemas.android.com/tools"> <style name="Theme.Bimba" parent="Theme.Material3.Dark.NoActionBar"> <item name="android:fontFamily">@font/yellowcircle8</item> @@ -31,7 +31,7 @@- @color/md_theme_dark_inversePrimary
<item name="statusBarBackground">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item> - <item name="android:enforceStatusBarContrast" tools:targetApi="q">false</item> + <item name="android:enforceStatusBarContrast" tool:targetApi="q">false</item> <item name="lightStatusBar">false</item> </style> diff --git a/build.gradle b/build.gradle index 74d65fbdbccb43c494abe0c8ebe108e5094f72a0..73d1e088368f91b43d6e9b60b37550773df667cd 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.3.1' apply false - id 'com.android.library' version '7.3.1' apply false + id 'com.android.application' version '7.4.2' apply false + id 'com.android.library' version '7.4.2' apply false id 'org.jetbrains.kotlin.android' version '1.7.10' apply false id 'org.jetbrains.kotlin.jvm' version '1.7.20' apply false + id "org.jetbrains.kotlin.plugin.parcelize" version "1.8.20" apply false } task clean(type: Delete) { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 87e44bc8e7fa6c9359d8cc8d6679a8389e1778b0..72a0f4c89357e1fdc5ad0b2d63031ab0a9475f5b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Aug 09 15:48:25 CEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME