diff --git a/LICENSE b/LICENSE
index f288702..be3f7b2 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,5 +1,5 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
+our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
-software for all its users. 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.
+software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ 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.
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
- "This License" refers to version 3 of the GNU General Public License.
+ "This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ 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.
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
+under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
-the GNU 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
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
+Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
+GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
+versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
+ it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
+ GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License
+ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
+For more information on this, and how to apply and follow the GNU AGPL, see
.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
diff --git a/assets/legal/eula.md b/assets/legal/eula.md
new file mode 100644
index 0000000..20ada6d
--- /dev/null
+++ b/assets/legal/eula.md
@@ -0,0 +1,173 @@
+# End User License Agreement (EULA)
+
+**Effective Date:** February 21, 2026
+**Last Updated:** February 21, 2026
+
+This End User License Agreement ("Agreement" or "EULA") is a legally binding agreement between you ("User" or "you") and the Coves Team ("we," "us," or "our") governing your use of the Coves mobile application ("App") available on Apple's App Store and Google Play Store.
+
+By downloading, installing, or using the App, you agree to be bound by the terms of this Agreement. If you do not agree to these terms, do not download, install, or use the App.
+
+---
+
+## 1. Acknowledgement
+
+This Agreement is between you and the Coves Team only, and not with Apple Inc. ("Apple"), Google LLC ("Google"), or any other third party. The Coves Team, not Apple or Google, is solely responsible for the App and its content. This Agreement does not provide usage rules that conflict with the Apple Media Services Terms and Conditions or Google Play Terms of Service as of the date you agree to this EULA.
+
+## 2. License Grant
+
+Subject to your compliance with this Agreement, we grant you a limited, non-exclusive, non-transferable, revocable license to download, install, and use the App on mobile devices that you own or control, solely for your personal, non-commercial purposes, and in accordance with the usage rules set forth in the Apple App Store Terms of Service or Google Play Terms of Service, as applicable.
+
+This license also permits family members who share a device with you, or those purchasing through volume purchasing programs, to use the App under the same terms, where permitted by platform-specific usage rules.
+
+## 3. Age Requirement
+
+The App is intended for users who are **18 years of age or older**. By using the App, you represent and warrant that you are at least 18 years old. If you are under 18, you may not download, install, or use the App.
+
+For full details, please review our [Child Safety Policy](https://coves.social/safety/child-safety).
+
+## 4. Account and Authentication
+
+The App uses the AT Protocol ("atproto") for decentralized authentication. When you log in, you authenticate through your atproto provider (e.g., Bluesky) using OAuth 2.0. We do not store your password. Authentication tokens are encrypted and stored securely on your device using platform-specific secure storage (iOS Keychain or Android EncryptedSharedPreferences).
+
+You are responsible for maintaining the security of your atproto account and any activity that occurs through your use of the App.
+
+## 5. User-Generated Content
+
+### 5.1 Your Content
+
+The App allows you to create and share content including posts, comments, votes, and profile information ("User Content"). Your User Content is stored on your Personal Data Server ("PDS") as part of the atproto network and is subject to the terms and architecture of that protocol.
+
+You retain ownership of your User Content. By posting User Content through the App, you grant us a non-exclusive, worldwide, royalty-free license to display, distribute, and index your User Content solely for the purpose of operating and providing the App's services. This license ends when you delete your User Content or your account, subject to the federated nature of the atproto protocol (content that has been distributed to other servers on the network may persist beyond deletion from our service).
+
+### 5.2 Prohibited Content
+
+You agree not to post, upload, or share any content that:
+
+- Contains child sexual abuse material (CSAM) or depicts minors in sexual contexts, whether real, synthetic, AI-generated, or animated
+- Promotes grooming, exploitation, trafficking, or predatory conduct toward minors
+- Is unlawful, defamatory, harassing, threatening, or fraudulent
+- Infringes on the intellectual property rights of any third party
+- Contains malware, spam, or other harmful material
+- Violates any applicable law or regulation
+
+We reserve the right to remove any content and terminate accounts that violate these terms. Confirmed CSAM is reported to NCMEC and relevant law enforcement as required by law.
+
+## 6. Open Source License and Restrictions
+
+### 6.1 Open Source
+
+The App's source code is licensed under the GNU Affero General Public License v3.0 ("AGPL-3.0"). Nothing in this Agreement restricts your rights under the AGPL-3.0, including the right to copy, modify, and distribute the source code in accordance with that license. In the event of any conflict between this Agreement and the AGPL-3.0, the AGPL-3.0 shall prevail with respect to the source code.
+
+The full text of the AGPL-3.0 license is available at https://www.gnu.org/licenses/agpl-3.0.html and is included with the App's source code.
+
+### 6.2 Service Usage Restrictions
+
+While the source code is freely available under the AGPL-3.0, your use of the Coves service (including our servers, APIs, and infrastructure) is subject to the following restrictions. You agree not to:
+
+- Use the Coves service for any unlawful, fraudulent, or malicious purpose
+- Attempt to bypass or circumvent any security features of the Coves service
+- Use automated systems (bots, scrapers, crawlers) to access the Coves service without our express written permission
+- Impersonate another person or entity on the Coves service
+- Interfere with or disrupt the Coves service or its infrastructure
+- Use the Coves service to distribute malware, spam, or other harmful material
+
+## 7. Intellectual Property
+
+### 7.1 Source Code
+
+The App's source code is licensed under the AGPL-3.0 and is subject to the terms of that license. You are free to use, modify, and distribute the source code in accordance with the AGPL-3.0.
+
+### 7.2 Trademarks and Branding
+
+The Coves name, logo, and related trademarks and trade dress are the property of the Coves Team and are not covered by the AGPL-3.0. These may not be used in connection with any product or service without our prior written consent, except as required for reasonable and customary attribution when redistributing the source code under the AGPL-3.0.
+
+### 7.3 Third-Party IP Claims
+
+In the event of any third-party intellectual property infringement claim relating to the App, the Coves Team, not Apple or Google, will be solely responsible for the investigation, defense, settlement, and discharge of any such claim.
+
+## 8. Privacy and Data
+
+Your privacy is important to us. Our collection and use of personal information is governed by our [Privacy Policy](https://coves.social/privacy), which is incorporated into this Agreement by reference.
+
+Key points:
+- Your data is stored on your Personal Data Server (PDS), which you control
+- We do not collect passwords, device identifiers, location data, or advertising identifiers
+- Authentication tokens are encrypted on your device
+- Our servers are located in Canada and are used for indexing public atproto data
+- We use Cloudflare for content delivery; no analytics or advertising networks are employed
+
+## 9. Account Deletion
+
+You may delete your Coves account at any time by visiting [https://coves.social/delete-account](https://coves.social/delete-account). Upon deletion, all your Coves data will be permanently removed, including posts, comments, votes, and subscriptions. Your atproto identity (DID and handle) will be preserved, and you will retain access to your Bluesky account and other atproto applications.
+
+Due to the federated nature of the atproto protocol, content previously distributed to other servers on the network may persist after deletion from our service.
+
+## 10. Maintenance and Support
+
+The Coves Team is solely responsible for providing maintenance and support for the App. Apple and Google have no obligation to provide any maintenance or support services with respect to the App.
+
+For support inquiries, please contact us at **support@coves.social**.
+
+## 11. Warranty Disclaimer
+
+THE APP IS PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
+
+We do not warrant that the App will be uninterrupted, error-free, or free of harmful components. You use the App at your own risk.
+
+In the event of any failure of the App to conform to any applicable warranty, you may notify Apple or Google, and they may refund the purchase price (if any) for the App. To the maximum extent permitted by applicable law, Apple and Google will have no other warranty obligation whatsoever with respect to the App.
+
+## 12. Limitation of Liability
+
+TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE COVES TEAM, ITS AFFILIATES, OR THEIR RESPECTIVE OFFICERS, DIRECTORS, EMPLOYEES, OR AGENTS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, USE, OR GOODWILL, ARISING OUT OF OR IN CONNECTION WITH YOUR USE OF THE APP, WHETHER BASED ON WARRANTY, CONTRACT, TORT (INCLUDING NEGLIGENCE), OR ANY OTHER LEGAL THEORY, EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+The Coves Team, not Apple or Google, is responsible for addressing any claims by you or any third party relating to the App or your possession and use of the App, including but not limited to: (a) product liability claims; (b) any claim that the App fails to conform to any applicable legal or regulatory requirement; and (c) claims arising under consumer protection, privacy, or similar legislation.
+
+## 13. Indemnification
+
+You agree to indemnify, defend, and hold harmless the Coves Team and its officers, directors, employees, and agents from and against any claims, liabilities, damages, losses, and expenses (including reasonable legal fees) arising out of or in connection with: (a) your use of the App; (b) your User Content; (c) your violation of this Agreement; or (d) your violation of any rights of another party.
+
+## 14. Legal Compliance
+
+You represent and warrant that:
+
+- You are not located in a country that is subject to a U.S. Government embargo or that has been designated by the U.S. Government as a "terrorist supporting" country
+- You are not listed on any U.S. Government list of prohibited or restricted parties
+- You will comply with all applicable local, state, national, and international laws and regulations in your use of the App
+
+## 15. Third-Party Terms
+
+You must comply with applicable third-party terms of agreement when using the App, including but not limited to your atproto provider's terms of service, your wireless data service agreement, and the Apple App Store or Google Play Terms of Service.
+
+## 16. Third-Party Beneficiary
+
+You acknowledge and agree that Apple and its subsidiaries are third-party beneficiaries of this Agreement. Upon your acceptance of the terms of this Agreement, Apple will have the right (and will be deemed to have accepted the right) to enforce this Agreement against you as a third-party beneficiary thereof.
+
+## 17. Termination
+
+We may terminate or suspend your access to the App immediately, without prior notice or liability, for any reason, including without limitation if you breach this Agreement.
+
+Upon termination, your right to use the App will immediately cease. Provisions of this Agreement that by their nature should survive termination shall survive, including but not limited to ownership provisions, warranty disclaimers, indemnification, and limitations of liability.
+
+## 18. Changes to This Agreement
+
+We reserve the right to modify this Agreement at any time. We will notify you of any material changes by updating the "Last Updated" date at the top of this Agreement and, where practicable, by providing notice through the App. Your continued use of the App after any such changes constitutes your acceptance of the revised Agreement.
+
+## 19. Governing Law
+
+This Agreement shall be governed by and construed in accordance with the laws of Canada, without regard to its conflict of law provisions. Any disputes arising from this Agreement shall be resolved in the courts of competent jurisdiction in Canada.
+
+## 20. Severability
+
+If any provision of this Agreement is held to be invalid or unenforceable, the remaining provisions shall continue in full force and effect. The invalid or unenforceable provision shall be modified to the minimum extent necessary to make it valid and enforceable.
+
+## 21. Entire Agreement
+
+This Agreement, together with our [Privacy Policy](https://coves.social/privacy) and [Child Safety Policy](https://coves.social/safety/child-safety), constitutes the entire agreement between you and the Coves Team regarding the App and supersedes all prior agreements and understandings.
+
+## 22. Contact Information
+
+If you have any questions about this Agreement, please contact us:
+
+**Coves Team**
+Email: support@coves.social
+Website: https://coves.social
diff --git a/lib/main.dart b/lib/main.dart
index 77efcae..ea241a7 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -14,10 +14,12 @@ import 'models/post.dart';
import 'providers/auth_provider.dart';
import 'providers/block_provider.dart';
import 'providers/community_subscription_provider.dart';
+import 'providers/eula_provider.dart';
import 'providers/multi_feed_provider.dart';
import 'providers/user_profile_provider.dart';
import 'providers/vote_provider.dart';
import 'screens/auth/login_screen.dart';
+import 'screens/eula_screen.dart';
import 'screens/community/community_feed_screen.dart';
import 'screens/home/main_shell_screen.dart';
import 'screens/home/post_detail_screen.dart';
@@ -70,6 +72,11 @@ Future main() async {
);
}
+ // Initialize EULA acceptance provider
+ // Note: initialize() handles errors internally (fail-closed design)
+ final eulaProvider = EulaProvider();
+ await eulaProvider.initialize();
+
// Initialize vote service with auth callbacks
// Votes go through the Coves backend (which proxies to PDS with DPoP)
// Includes token refresh and sign-out handlers for automatic 401 recovery
@@ -92,6 +99,7 @@ Future main() async {
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: authProvider),
+ ChangeNotifierProvider.value(value: eulaProvider),
ChangeNotifierProvider(
create:
(_) => VoteProvider(
@@ -187,6 +195,7 @@ class CovesApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final authProvider = Provider.of(context, listen: false);
+ final eulaProvider = Provider.of(context, listen: false);
return MaterialApp.router(
title: 'Coves',
@@ -197,7 +206,7 @@ class CovesApp extends StatelessWidget {
),
useMaterial3: true,
),
- routerConfig: _createRouter(authProvider),
+ routerConfig: _createRouter(authProvider, eulaProvider),
restorationScopeId: 'app',
debugShowCheckedModeBanner: false,
);
@@ -205,11 +214,18 @@ class CovesApp extends StatelessWidget {
}
// GoRouter configuration factory
-GoRouter _createRouter(AuthProvider authProvider) {
+GoRouter _createRouter(AuthProvider authProvider, EulaProvider eulaProvider) {
return GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => const LandingScreen()),
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
+ GoRoute(
+ path: '/eula',
+ builder: (context, state) {
+ final viewOnly = state.uri.queryParameters['viewOnly'] == 'true';
+ return EulaScreen(viewOnly: viewOnly);
+ },
+ ),
GoRoute(
path: '/feed',
builder: (context, state) => const MainShellScreen(),
@@ -260,17 +276,31 @@ GoRouter _createRouter(AuthProvider authProvider) {
},
),
],
- refreshListenable: authProvider,
+ refreshListenable: Listenable.merge([authProvider, eulaProvider]),
redirect: (context, state) {
final isAuthenticated = authProvider.isAuthenticated;
- final isLoading = authProvider.isLoading;
+ final isAuthLoading = authProvider.isLoading;
+ final eulaAccepted = eulaProvider.hasAccepted;
+ final isEulaLoading = eulaProvider.isLoading;
final currentPath = state.uri.path;
- // Don't redirect while loading initial auth state
- if (isLoading) {
+ // Don't redirect while loading initial state
+ if (isAuthLoading || isEulaLoading) {
return null;
}
+ // EULA must be accepted before accessing any other route
+ if (!eulaAccepted && currentPath != '/eula') {
+ return '/eula';
+ }
+
+ // Prevent navigating to /eula accept mode after already accepting
+ if (eulaAccepted &&
+ currentPath == '/eula' &&
+ state.uri.queryParameters['viewOnly'] != 'true') {
+ return '/';
+ }
+
// If authenticated and on landing/login screen, redirect to feed
if (isAuthenticated && (currentPath == '/' || currentPath == '/login')) {
if (kDebugMode) {
diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart
index b2ae8ab..9418169 100644
--- a/lib/models/user_profile.dart
+++ b/lib/models/user_profile.dart
@@ -1,7 +1,7 @@
// User profile data models for Coves
//
// These models match the backend response structure from:
-// /xrpc/social.coves.actor.getprofile
+// /xrpc/social.coves.actor.getProfile
/// User profile with display information and stats
class UserProfile {
diff --git a/lib/providers/eula_provider.dart b/lib/providers/eula_provider.dart
new file mode 100644
index 0000000..a367a7c
--- /dev/null
+++ b/lib/providers/eula_provider.dart
@@ -0,0 +1,52 @@
+import 'package:flutter/foundation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// Tracks whether the user has accepted the current EULA version.
+///
+/// Uses shared_preferences to persist acceptance state.
+/// Increment [currentEulaVersion] when the EULA changes to re-prompt users.
+class EulaProvider with ChangeNotifier {
+ static const String _eulaAcceptedKey = 'eula_accepted_version';
+ static const int currentEulaVersion = 1;
+
+ bool _hasAccepted = false;
+ bool _isLoading = true;
+ String? _error;
+
+ bool get hasAccepted => _hasAccepted;
+ bool get isLoading => _isLoading;
+ String? get error => _error;
+
+ Future initialize() async {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ final acceptedVersion = prefs.getInt(_eulaAcceptedKey) ?? 0;
+ _hasAccepted = acceptedVersion >= currentEulaVersion;
+ } on Exception catch (e, stackTrace) {
+ if (kDebugMode) {
+ print('Failed to check EULA acceptance: $e\n$stackTrace');
+ }
+ // Fail closed - require acceptance if we can't read state
+ _hasAccepted = false;
+ } finally {
+ _isLoading = false;
+ notifyListeners();
+ }
+ }
+
+ Future acceptEula() async {
+ _error = null;
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setInt(_eulaAcceptedKey, currentEulaVersion);
+ _hasAccepted = true;
+ } on Exception catch (e, stackTrace) {
+ if (kDebugMode) {
+ print('Failed to accept EULA: $e\n$stackTrace');
+ }
+ _error = 'Failed to save EULA acceptance. Please try again.';
+ } finally {
+ notifyListeners();
+ }
+ }
+}
diff --git a/lib/screens/eula_screen.dart b/lib/screens/eula_screen.dart
new file mode 100644
index 0000000..d0cc528
--- /dev/null
+++ b/lib/screens/eula_screen.dart
@@ -0,0 +1,484 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_markdown/flutter_markdown.dart';
+import 'package:google_fonts/google_fonts.dart';
+import 'package:provider/provider.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../constants/app_colors.dart';
+import '../providers/eula_provider.dart';
+
+class EulaScreen extends StatefulWidget {
+ const EulaScreen({this.viewOnly = false, super.key});
+
+ final bool viewOnly;
+
+ @override
+ State createState() => _EulaScreenState();
+}
+
+class _EulaScreenState extends State {
+ final ScrollController _scrollController = ScrollController();
+ bool _hasScrolledToBottom = false;
+ bool _hasAgreed = false;
+ bool _isAccepting = false;
+ late Future _eulaFuture;
+
+ @override
+ void initState() {
+ super.initState();
+ _eulaFuture = rootBundle.loadString('assets/legal/eula.md');
+ if (!widget.viewOnly) {
+ _scrollController.addListener(_onScroll);
+ }
+ }
+
+ @override
+ void dispose() {
+ _scrollController.removeListener(_onScroll);
+ _scrollController.dispose();
+ super.dispose();
+ }
+
+ void _onScroll() {
+ if (_hasScrolledToBottom) return;
+ final maxScroll = _scrollController.position.maxScrollExtent;
+ final currentScroll = _scrollController.position.pixels;
+ // Trigger when user is within 40px of the bottom
+ if (currentScroll >= maxScroll - 40) {
+ setState(() => _hasScrolledToBottom = true);
+ }
+ }
+
+ Future _handleAccept() async {
+ setState(() => _isAccepting = true);
+ try {
+ final eulaProvider = context.read();
+ await eulaProvider.acceptEula();
+ // Router redirect handles navigation automatically
+ } on Exception catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Failed to accept agreement: $e'),
+ backgroundColor: AppColors.error,
+ ),
+ );
+ } finally {
+ if (mounted) {
+ setState(() => _isAccepting = false);
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: AppColors.background,
+ appBar: widget.viewOnly
+ ? AppBar(
+ backgroundColor: AppColors.background,
+ surfaceTintColor: Colors.transparent,
+ title: Text(
+ 'End User License Agreement',
+ style: GoogleFonts.nunito(
+ color: AppColors.textPrimary,
+ fontSize: 18,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ leading: IconButton(
+ icon:
+ const Icon(Icons.arrow_back, color: AppColors.textPrimary),
+ onPressed: () => Navigator.pop(context),
+ ),
+ )
+ : null,
+ body: SafeArea(
+ child: Column(
+ children: [
+ if (!widget.viewOnly) _buildHeader(),
+ Expanded(
+ child: _buildAgreementBody(),
+ ),
+ if (!widget.viewOnly) _buildBottomBar(),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildHeader() {
+ return Container(
+ padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
+ child: Row(
+ children: [
+ Container(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ color: AppColors.coral.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: const Icon(
+ Icons.gavel_rounded,
+ color: AppColors.coral,
+ size: 22,
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'License Agreement',
+ style: GoogleFonts.nunito(
+ fontSize: 18,
+ fontWeight: FontWeight.w800,
+ color: AppColors.textPrimary,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ 'Please read the agreement before continuing',
+ style: GoogleFonts.nunito(
+ fontSize: 13,
+ color: AppColors.textSecondary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _retryLoadEula() {
+ setState(() {
+ _eulaFuture = rootBundle.loadString('assets/legal/eula.md');
+ });
+ }
+
+ Future _handleLinkTap(String? href) async {
+ if (href == null) return;
+ final uri = Uri.tryParse(href);
+ if (uri == null) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Invalid link: $href'),
+ backgroundColor: AppColors.error,
+ ),
+ );
+ return;
+ }
+ final launched = await launchUrl(
+ uri,
+ mode: LaunchMode.externalApplication,
+ );
+ if (!launched && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Could not open link: $href'),
+ backgroundColor: AppColors.error,
+ ),
+ );
+ }
+ }
+
+ Widget _buildAgreementBody() {
+ return Container(
+ margin: const EdgeInsets.symmetric(horizontal: 16),
+ decoration: BoxDecoration(
+ color: AppColors.backgroundSecondary,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: AppColors.border, width: 1),
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(12),
+ child: FutureBuilder(
+ future: _eulaFuture,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.waiting) {
+ return const Center(
+ child: CircularProgressIndicator(
+ color: AppColors.coral,
+ strokeWidth: 2,
+ ),
+ );
+ }
+
+ if (snapshot.hasError) {
+ debugPrint(
+ 'Error loading EULA: ${snapshot.error}');
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ 'Error loading agreement',
+ style: GoogleFonts.nunito(color: AppColors.error),
+ ),
+ const SizedBox(height: 12),
+ TextButton.icon(
+ onPressed: _retryLoadEula,
+ icon: const Icon(Icons.refresh, size: 18),
+ label: Text(
+ 'Retry',
+ style: GoogleFonts.nunito(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Stack(
+ children: [
+ Markdown(
+ controller: _scrollController,
+ data: snapshot.data ?? '',
+ styleSheet: _buildMarkdownStyle(),
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
+ onTapLink: (text, href, title) => _handleLinkTap(href),
+ ),
+ // Scroll hint at the bottom when user hasn't scrolled down yet
+ if (!widget.viewOnly && !_hasScrolledToBottom)
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: _buildScrollHint(),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ );
+ }
+
+ Widget _buildScrollHint() {
+ return Container(
+ padding: const EdgeInsets.only(bottom: 10, top: 24),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ AppColors.backgroundSecondary.withValues(alpha: 0.0),
+ AppColors.backgroundSecondary.withValues(alpha: 0.95),
+ AppColors.backgroundSecondary,
+ ],
+ stops: const [0.0, 0.5, 1.0],
+ ),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(
+ Icons.keyboard_double_arrow_down_rounded,
+ color: AppColors.textMuted,
+ size: 16,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ 'Scroll to read full agreement',
+ style: GoogleFonts.nunito(
+ fontSize: 12,
+ color: AppColors.textMuted,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildBottomBar() {
+ final bool canAccept =
+ _hasScrolledToBottom && _hasAgreed && !_isAccepting;
+
+ return ClipRect(
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
+ child: Container(
+ padding: const EdgeInsets.fromLTRB(24, 16, 24, 20),
+ decoration: BoxDecoration(
+ color: AppColors.background.withValues(alpha: 0.85),
+ border: const Border(
+ top: BorderSide(color: AppColors.border, width: 1),
+ ),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // Checkbox row
+ GestureDetector(
+ onTap: _hasScrolledToBottom
+ ? () => setState(() => _hasAgreed = !_hasAgreed)
+ : null,
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: Row(
+ children: [
+ _buildCheckbox(),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ 'I have read and agree to the End User License Agreement',
+ style: GoogleFonts.nunito(
+ fontSize: 13,
+ color: _hasScrolledToBottom
+ ? AppColors.textPrimary
+ : AppColors.textMuted,
+ fontWeight: FontWeight.w600,
+ height: 1.4,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ // Accept button
+ SizedBox(
+ width: double.infinity,
+ height: 48,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(10),
+ color: canAccept
+ ? AppColors.coral
+ : AppColors.coral.withValues(alpha: 0.2),
+ ),
+ child: Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: canAccept ? _handleAccept : null,
+ borderRadius: BorderRadius.circular(10),
+ splashFactory: NoSplash.splashFactory,
+ highlightColor: Colors.transparent,
+ child: Center(
+ child: _isAccepting
+ ? const SizedBox(
+ width: 22,
+ height: 22,
+ child: CircularProgressIndicator(
+ color: AppColors.background,
+ strokeWidth: 2,
+ ),
+ )
+ : Text(
+ 'Accept & Continue',
+ style: GoogleFonts.nunito(
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ color: canAccept
+ ? AppColors.background
+ : AppColors.textMuted,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildCheckbox() {
+ final bool enabled = _hasScrolledToBottom;
+
+ return AnimatedContainer(
+ duration: const Duration(milliseconds: 150),
+ width: 22,
+ height: 22,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(6),
+ color: _hasAgreed
+ ? AppColors.coral
+ : Colors.transparent,
+ border: Border.all(
+ color: _hasAgreed
+ ? AppColors.coral
+ : enabled
+ ? AppColors.textSecondary
+ : AppColors.textMuted.withValues(alpha: 0.4),
+ width: 2,
+ ),
+ ),
+ child: _hasAgreed
+ ? const Icon(
+ Icons.check_rounded,
+ size: 16,
+ color: AppColors.background,
+ )
+ : null,
+ );
+ }
+
+ MarkdownStyleSheet _buildMarkdownStyle() {
+ return MarkdownStyleSheet(
+ h1: GoogleFonts.nunito(
+ fontSize: 20,
+ fontWeight: FontWeight.w800,
+ color: AppColors.textPrimary,
+ height: 1.3,
+ ),
+ h2: GoogleFonts.nunito(
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: AppColors.coral,
+ height: 1.3,
+ ),
+ h3: GoogleFonts.nunito(
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ color: AppColors.textPrimary,
+ height: 1.4,
+ ),
+ p: GoogleFonts.nunito(
+ fontSize: 13,
+ color: AppColors.textSecondary,
+ height: 1.6,
+ ),
+ a: GoogleFonts.nunito(
+ fontSize: 13,
+ color: AppColors.teal,
+ decoration: TextDecoration.underline,
+ ),
+ listBullet: GoogleFonts.nunito(
+ fontSize: 13,
+ color: AppColors.textSecondary,
+ ),
+ strong: GoogleFonts.nunito(
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ color: AppColors.textPrimary,
+ ),
+ blockSpacing: 10.0,
+ listIndent: 16.0,
+ horizontalRuleDecoration: BoxDecoration(
+ border: Border(
+ top: BorderSide(
+ color: AppColors.border,
+ width: 1,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/home/profile_screen.dart b/lib/screens/home/profile_screen.dart
index 8401fe0..59143cd 100644
--- a/lib/screens/home/profile_screen.dart
+++ b/lib/screens/home/profile_screen.dart
@@ -2,6 +2,7 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
+import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import '../../constants/app_colors.dart';
import '../../utils/responsive_utils.dart';
@@ -113,6 +114,25 @@ class _ProfileScreenState extends State {
),
),
const SizedBox(height: 16),
+ // View EULA
+ ListTile(
+ leading: const Icon(
+ Icons.description_outlined,
+ color: AppColors.textSecondary,
+ ),
+ title: Text(
+ 'End User License Agreement',
+ style: GoogleFonts.nunito(
+ color: AppColors.textPrimary,
+ fontSize: 16,
+ ),
+ ),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ context.push('/eula?viewOnly=true');
+ },
+ ),
+ const Divider(color: AppColors.border, height: 1),
// Sign out option
ListTile(
leading: Icon(
diff --git a/lib/services/coves_api_service.dart b/lib/services/coves_api_service.dart
index 87bc3e5..ac44d50 100644
--- a/lib/services/coves_api_service.dart
+++ b/lib/services/coves_api_service.dart
@@ -721,7 +721,7 @@ class CovesApiService {
}
final response = await _dio.get(
- '/xrpc/social.coves.actor.getprofile',
+ '/xrpc/social.coves.actor.getProfile',
queryParameters: {'actor': actor},
);
diff --git a/pubspec.lock b/pubspec.lock
index e520740..fd05105 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -334,6 +334,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
+ flutter_markdown:
+ dependency: "direct main"
+ description:
+ name: flutter_markdown
+ sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.7+1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -664,6 +672,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
+ markdown:
+ dependency: transitive
+ description:
+ name: markdown
+ sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.3.0"
matcher:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 9aa740b..959616d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -47,6 +47,7 @@ dependencies:
flutter_web_auth_2: ^4.1.0
cached_network_image: ^3.4.1
url_launcher: ^6.3.1
+ flutter_markdown: ^0.7.4+1
video_player: ^2.8.7 # Pinned for stability
share_plus: ^10.1.4
sentry_flutter: ^8.10.1
@@ -101,6 +102,7 @@ flutter:
- assets/icons/
- assets/icons/atproto/
- assets/images/
+ - assets/legal/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images