Bimba.git

commit 9f1a3c4167d7d866520a69ac31d3c7615205397b

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