mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: ToS & privacy policy

* release audit

* wip website images

+777 -23
+2 -3
android/app/build.gradle.kts
··· 6 6 } 7 7 8 8 android { 9 - namespace = "com.example.lazurite" 9 + namespace = "org.stormlightlabs.lazurite" 10 10 compileSdk = flutter.compileSdkVersion 11 11 ndkVersion = flutter.ndkVersion 12 12 ··· 20 20 } 21 21 22 22 defaultConfig { 23 - // Replace this placeholder application ID before shipping a release build. 24 - applicationId = "com.example.lazurite" 23 + applicationId = "org.stormlightlabs.lazurite" 25 24 // You can update the following values to match your application needs. 26 25 // For more information, see: https://flutter.dev/to/review-gradle-config. 27 26 minSdk = flutter.minSdkVersion
+1 -1
android/app/src/main/kotlin/com/example/lazurite/MainActivity.kt
··· 1 - package com.example.lazurite 1 + package org.stormlightlabs.lazurite 2 2 3 3 import io.flutter.embedding.android.FlutterActivity 4 4
+101
docs/release.md
··· 1 + --- 2 + title: Release Audit - Apple App Store + Google Play 3 + updated: 2026-04-15 4 + scope: Repository audit for likely store submission blockers or high-risk policy issues. 5 + --- 6 + 7 + ## Sources 8 + 9 + - Apple App Review Guidelines: <https://developer.apple.com/app-store/review/guidelines/> 10 + - 1.2 User-Generated Content requirements 11 + - 5.1.1 Data Collection and Storage (Privacy Policies) 12 + - 4.8 Login Services (third-party login exception for service clients) 13 + - Google Play User Data policy: <https://support.google.com/googleplay/android-developer/answer/10144311?hl=en> 14 + - Privacy Policy requirement 15 + - Account deletion requirement (if account creation is supported) 16 + - Google Play Developer Programme Policy (UGC section): <https://support.google.com/googleplay/android-developer/answer/16070163> 17 + - UGC terms acceptance + moderation expectations 18 + 19 + ## Findings 20 + 21 + ### No in-app Privacy Policy link/text 22 + 23 + - Policy mapping: 24 + - Apple 5.1.1(i): privacy policy must be linked in App Store Connect and in-app in an easily accessible manner. 25 + - Google Play User Data: privacy policy link/text must exist in Play Console and in-app. 26 + - Evidence in app: 27 + - Login has no privacy/terms surface: `lib/features/auth/presentation/login_screen.dart` (see UI around lines 54-205). 28 + - Settings has no legal/privacy entry: `lib/features/settings/presentation/settings_screen.dart` (lines 69-151). 29 + - About page has external links and email only, no privacy policy link: `lib/features/settings/presentation/about_screen.dart` (lines 8-97). 30 + - Existing backlog confirms missing policy: `docs/TODO.md` (lines 62-65). 31 + - Impact: High probability of rejection by both stores until fixed. 32 + 33 + ### High Risk (Google Play UGC): No explicit Terms/User Policy acceptance before posting UGC 34 + 35 + - Policy mapping: 36 + - Google Play UGC policy requires robust moderation, including requiring acceptance of Terms of Use and/or user policy before users create/upload UGC. 37 + - Evidence in app: 38 + - Compose allows direct posting with no terms acceptance gate: `lib/features/compose/presentation/compose_screen.dart` (lines 567-588, especially Post action at 584-587). 39 + - No in-app terms/user policy screen found in `lib/`. 40 + - Impact: Elevated Play policy risk for social/UGC apps. 41 + 42 + ### Moderate Risk (Apple UGC 1.2): Posting-side objectionable-content controls are not explicit 43 + 44 + - Policy mapping: 45 + - Apple 1.2 says UGC/social apps should include a method for filtering objectionable material from being posted. 46 + - Evidence: 47 + - Reporting/blocking exists (good): 48 + - `lib/features/profile/presentation/widgets/profile_action_buttons.dart` (Report/Block UI around lines 85-140). 49 + - `lib/features/profile/presentation/widgets/report_dialog.dart` (report flow lines 10-220). 50 + - Moderation controls exist for viewed content (good): `lib/features/settings/presentation/settings_screen.dart` (Moderation section lines 75-77). 51 + - No explicit compose-time objectionable-content filter is visible in compose flow. 52 + - Impact: Could pass if platform-side moderation is accepted by review, but still a non-trivial risk without clear reviewer notes. 53 + 54 + ### Release Engineering Blocker 55 + 56 + - Android release is configured to use debug signing: 57 + - `android/app/build.gradle.kts` lines 33-38. 58 + - Android application ID is still placeholder: 59 + - `android/app/build.gradle.kts` lines 23-25 (`com.example.lazurite`). 60 + - iOS bundle identifiers are still placeholder: 61 + - `ios/Runner.xcodeproj/project.pbxproj` lines 498, 681, 704 (`com.example.lazurite`). 62 + - iOS release config currently shows developer signing identity: 63 + - `ios/Runner.xcodeproj/project.pbxproj` line 642 (`iPhone Developer`). 64 + - Clarification: 65 + - `org.stormlightlabs.lazurite.auth` appears in `Info.plist` URL type name (`CFBundleURLName`) and is not itself a placeholder bundle ID. 66 + - Impact: Submission can fail operationally or be blocked in release pipeline. 67 + 68 + ### Reviewer-access risk for App Store 69 + 70 + - Apple "Before You Submit" requires full reviewer access (demo account or demo mode for account-based features). 71 + - App is account-based and login-gated; no repo evidence of dedicated reviewer/demo path. 72 + - Impact: Common review delay/rejection if review notes do not include working credentials. 73 + 74 + ## OK 75 + 76 + - In-app report mechanism exists for posts/accounts: 77 + - `lib/features/profile/presentation/widgets/report_dialog.dart`. 78 + - Block/mute actions exist: 79 + - `lib/features/profile/presentation/widgets/profile_action_buttons.dart`. 80 + - User-reachable contact info exists: 81 + - Email link in About: `lib/features/settings/presentation/about_screen.dart` line 11 + UI lines 90-93. 82 + - Sign in with Apple requirement appears likely exempt: 83 + - App behaves as a client for a specific third-party service (Bluesky), matching Apple 4.8 exception language. 84 + 85 + ## Fixes (In Priority Order) 86 + 87 + - [ ] Add a dedicated Legal screen and surface: 88 + - [ ] Privacy Policy (in-app link + readable text summary) 89 + - [ ] Terms of Use / User Policy 90 + - [ ] Reachable from login and settings/about 91 + - [ ] Add UGC policy acceptance flow before first create/upload action (compose, media upload, messages if applicable). 92 + - [ ] Document moderation operations in policy/reviewer notes: 93 + - [ ] How reports are handled and SLA 94 + - [ ] What objectionable content rules apply 95 + - [ ] Replace placeholder identifiers and release signing setup: 96 + - [ ] Android `applicationId` 97 + - [ ] Android release signing config (non-debug) 98 + - [ ] iOS bundle IDs + distribution signing 99 + - [ ] Prepare App Store review notes with working reviewer credentials/demo path. 100 + - [ ] Verify account deletion obligations: 101 + - [ ] If any account creation is enabled in-app, add in-app deletion entry point per Apple/Google rules.
+6 -6
ios/Runner.xcodeproj/project.pbxproj
··· 495 495 "$(inherited)", 496 496 "@executable_path/Frameworks", 497 497 ); 498 - PRODUCT_BUNDLE_IDENTIFIER = com.example.lazurite; 498 + PRODUCT_BUNDLE_IDENTIFIER = org.stormlightlabs.lazurite; 499 499 PRODUCT_NAME = "$(TARGET_NAME)"; 500 500 SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; 501 501 SWIFT_VERSION = 5.0; ··· 512 512 CURRENT_PROJECT_VERSION = 1; 513 513 GENERATE_INFOPLIST_FILE = YES; 514 514 MARKETING_VERSION = 1.0; 515 - PRODUCT_BUNDLE_IDENTIFIER = com.example.lazurite.RunnerTests; 515 + PRODUCT_BUNDLE_IDENTIFIER = org.stormlightlabs.lazurite.RunnerTests; 516 516 PRODUCT_NAME = "$(TARGET_NAME)"; 517 517 SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 518 518 SWIFT_OPTIMIZATION_LEVEL = "-Onone"; ··· 530 530 CURRENT_PROJECT_VERSION = 1; 531 531 GENERATE_INFOPLIST_FILE = YES; 532 532 MARKETING_VERSION = 1.0; 533 - PRODUCT_BUNDLE_IDENTIFIER = com.example.lazurite.RunnerTests; 533 + PRODUCT_BUNDLE_IDENTIFIER = org.stormlightlabs.lazurite.RunnerTests; 534 534 PRODUCT_NAME = "$(TARGET_NAME)"; 535 535 SWIFT_VERSION = 5.0; 536 536 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; ··· 546 546 CURRENT_PROJECT_VERSION = 1; 547 547 GENERATE_INFOPLIST_FILE = YES; 548 548 MARKETING_VERSION = 1.0; 549 - PRODUCT_BUNDLE_IDENTIFIER = com.example.lazurite.RunnerTests; 549 + PRODUCT_BUNDLE_IDENTIFIER = org.stormlightlabs.lazurite.RunnerTests; 550 550 PRODUCT_NAME = "$(TARGET_NAME)"; 551 551 SWIFT_VERSION = 5.0; 552 552 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; ··· 678 678 "$(inherited)", 679 679 "@executable_path/Frameworks", 680 680 ); 681 - PRODUCT_BUNDLE_IDENTIFIER = com.example.lazurite; 681 + PRODUCT_BUNDLE_IDENTIFIER = org.stormlightlabs.lazurite; 682 682 PRODUCT_NAME = "$(TARGET_NAME)"; 683 683 SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; 684 684 SWIFT_OPTIMIZATION_LEVEL = "-Onone"; ··· 701 701 "$(inherited)", 702 702 "@executable_path/Frameworks", 703 703 ); 704 - PRODUCT_BUNDLE_IDENTIFIER = com.example.lazurite; 704 + PRODUCT_BUNDLE_IDENTIFIER = org.stormlightlabs.lazurite; 705 705 PRODUCT_NAME = "$(TARGET_NAME)"; 706 706 SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; 707 707 SWIFT_VERSION = 5.0;
+9 -2
lib/core/router/app_router.dart
··· 60 60 import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; 61 61 import 'package:lazurite/features/settings/data/video_repository.dart'; 62 62 import 'package:lazurite/features/settings/presentation/about_screen.dart'; 63 + import 'package:lazurite/features/settings/presentation/privacy_policy_screen.dart'; 63 64 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 65 + import 'package:lazurite/features/settings/presentation/terms_of_service_screen.dart'; 64 66 import 'package:lazurite/features/settings/presentation/video_upload_limits_screen.dart'; 65 67 66 68 ComposeRouteArgs parseComposeRouteExtra(Object? extra) { ··· 132 134 observers: navigatorObserver != null ? [navigatorObserver!] : null, 133 135 redirect: (context, state) { 134 136 final isAuthenticated = authBloc.state.isAuthenticated; 135 - final isLoggingIn = state.uri.path == '/login'; 137 + final path = state.uri.path; 138 + final publicPaths = {'/login', '/terms', '/privacy'}; 139 + final isLoggingIn = path == '/login'; 140 + final isPublicPath = publicPaths.contains(path); 136 141 137 - if (!isAuthenticated && !isLoggingIn) { 142 + if (!isAuthenticated && !isPublicPath) { 138 143 return '/login'; 139 144 } 140 145 ··· 146 151 }, 147 152 routes: [ 148 153 GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), 154 + GoRoute(path: '/terms', builder: (context, state) => const TermsOfServiceScreen()), 155 + GoRoute(path: '/privacy', builder: (context, state) => const PrivacyPolicyScreen()), 149 156 GoRoute(path: '/notifications', redirect: (_, _) => '/alerts'), 150 157 GoRoute(path: '/messages', redirect: (_, _) => '/alerts/messages'), 151 158 GoRoute(
+13 -1
lib/features/auth/presentation/login_screen.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:flutter_svg/flutter_svg.dart'; 5 + import 'package:go_router/go_router.dart'; 5 6 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 7 7 8 class LoginScreen extends StatefulWidget { ··· 191 192 ), 192 193 const SizedBox(height: 8), 193 194 Text( 194 - 'Generate app passwords from BlueSky Settings -> App Passwords.', 195 + 'Can be generated via BlueSky\'s App Passwords section at bsky.app.', 195 196 style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 196 197 textAlign: TextAlign.center, 197 198 ), ··· 201 202 ), 202 203 ), 203 204 ], 205 + const SizedBox(height: 28), 206 + Wrap( 207 + alignment: WrapAlignment.center, 208 + crossAxisAlignment: WrapCrossAlignment.center, 209 + spacing: 8, 210 + children: [ 211 + TextButton(onPressed: () => context.push('/terms'), child: const Text('Terms of Service')), 212 + Text('•', style: theme.textTheme.bodySmall), 213 + TextButton(onPressed: () => context.push('/privacy'), child: const Text('Privacy Policy')), 214 + ], 215 + ), 204 216 ], 205 217 ), 206 218 ),
+147
lib/features/settings/presentation/privacy_policy_screen.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_svg/flutter_svg.dart'; 3 + import 'package:lazurite/features/settings/presentation/widgets/contact_section.dart'; 4 + import 'package:url_launcher/url_launcher.dart'; 5 + 6 + class PrivacyPolicyScreen extends StatelessWidget { 7 + const PrivacyPolicyScreen({super.key}); 8 + 9 + static const _effectiveDate = 'April 15, 2026'; 10 + static const _websiteUrl = 'https://stormlightlabs.org'; 11 + static const _emailUrl = 'mailto:info@stormlightlabs.org'; 12 + 13 + Future<void> _launch(String url) async { 14 + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); 15 + } 16 + 17 + @override 18 + Widget build(BuildContext context) { 19 + final theme = Theme.of(context); 20 + final colorScheme = theme.colorScheme; 21 + 22 + return Scaffold( 23 + appBar: AppBar(title: const Text('Lazurite\'s Privacy Policy')), 24 + body: ListView( 25 + padding: const EdgeInsets.all(24), 26 + children: [ 27 + Center( 28 + child: SvgPicture.asset( 29 + 'assets/logo.svg', 30 + width: 64, 31 + height: 64, 32 + colorFilter: ColorFilter.mode(colorScheme.primary, BlendMode.srcIn), 33 + ), 34 + ), 35 + const SizedBox(height: 16), 36 + Text( 37 + 'Privacy Policy', 38 + textAlign: TextAlign.center, 39 + style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700), 40 + ), 41 + const SizedBox(height: 8), 42 + Text( 43 + 'Effective $_effectiveDate', 44 + textAlign: TextAlign.center, 45 + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 46 + ), 47 + const SizedBox(height: 24), 48 + Text( 49 + 'Lazurite is a client for Bluesky. Core app behavior runs from your device, and we do not operate a ' 50 + 'developer backend for normal use.', 51 + style: theme.textTheme.bodyLarge, 52 + ), 53 + const SizedBox(height: 20), 54 + const _PolicySection( 55 + title: 'What the app stores on your device', 56 + paragraphs: [ 57 + 'Lazurite stores account session data, settings, cached content, and other app data locally so the app can work quickly and reliably.', 58 + 'This local data can include profile metadata, viewed posts, follows, lists, likes, drafts, and media caches.', 59 + ], 60 + ), 61 + const _PolicySection( 62 + title: 'How your data is used', 63 + paragraphs: [ 64 + 'Local data is used to keep you signed in, remember your preferences, improve loading performance, and support offline-friendly behavior.', 65 + 'Lazurite does not sell your personal information.', 66 + ], 67 + ), 68 + const _PolicySection( 69 + title: 'Network requests and third parties', 70 + paragraphs: [ 71 + 'When you use Lazurite, requests are sent directly from your device to Bluesky and related infrastructure.', 72 + 'Your use of Bluesky remains subject to Bluesky policies, terms, and moderation systems.', 73 + ], 74 + ), 75 + const _PolicySection( 76 + title: 'Permissions', 77 + paragraphs: [ 78 + 'If you choose to save media, Lazurite requests photo or storage permissions required by your platform.', 79 + 'Permissions are used only for the feature you invoke.', 80 + ], 81 + ), 82 + const _PolicySection( 83 + title: 'Diagnostics and logs', 84 + paragraphs: [ 85 + 'Lazurite keeps local app logs to help troubleshoot issues. These logs stay on your device unless you choose to share them.', 86 + 'Lazurite does not include ad tracking SDKs.', 87 + ], 88 + ), 89 + const _PolicySection( 90 + title: 'Data retention and control', 91 + paragraphs: [ 92 + 'Data remains on your device until you remove it by signing out, clearing app storage, or uninstalling the app.', 93 + 'Because Lazurite does not run a central app backend for normal use, most data-control actions happen on your device or through Bluesky account settings.', 94 + ], 95 + ), 96 + const _PolicySection( 97 + title: 'Children', 98 + paragraphs: [ 99 + 'Lazurite is not directed to children under 13, or under the minimum age required in your jurisdiction.', 100 + ], 101 + ), 102 + const _PolicySection( 103 + title: 'Policy updates', 104 + paragraphs: [ 105 + 'We may revise this policy from time to time. Material updates will be reflected by a new effective date and app release notes when appropriate.', 106 + ], 107 + ), 108 + ContactSection(onStormlightLabsTap: () => _launch(_websiteUrl), onEmailTap: () => _launch(_emailUrl)), 109 + const SizedBox(height: 12), 110 + Center(child: Text('Lazurite v1.0.0', style: theme.textTheme.bodySmall)), 111 + ], 112 + ), 113 + ); 114 + } 115 + } 116 + 117 + class _PolicySection extends StatelessWidget { 118 + const _PolicySection({required this.title, required this.paragraphs}); 119 + 120 + final String title; 121 + final List<String> paragraphs; 122 + 123 + @override 124 + Widget build(BuildContext context) { 125 + final theme = Theme.of(context); 126 + final colorScheme = theme.colorScheme; 127 + 128 + return Padding( 129 + padding: const EdgeInsets.only(bottom: 18), 130 + child: Column( 131 + crossAxisAlignment: CrossAxisAlignment.start, 132 + children: [ 133 + Text( 134 + title, 135 + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700, color: colorScheme.primary), 136 + ), 137 + const SizedBox(height: 6), 138 + for (final paragraph in paragraphs) 139 + Padding( 140 + padding: const EdgeInsets.only(bottom: 8), 141 + child: Text(paragraph, style: theme.textTheme.bodyMedium), 142 + ), 143 + ], 144 + ), 145 + ); 146 + } 147 + }
+12
lib/features/settings/presentation/settings_screen.dart
··· 135 135 subtitle: 'Stormlight Labs', 136 136 onTap: () => context.push('/settings/about'), 137 137 ), 138 + _SettingsTile( 139 + icon: Icons.gavel_outlined, 140 + title: 'Terms of Service', 141 + subtitle: 'Usage rules and responsibilities', 142 + onTap: () => context.push('/terms'), 143 + ), 144 + _SettingsTile( 145 + icon: Icons.privacy_tip_outlined, 146 + title: 'Privacy Policy', 147 + subtitle: 'How Lazurite handles data', 148 + onTap: () => context.push('/privacy'), 149 + ), 138 150 const SizedBox(height: 24), 139 151 _buildSectionHeader(context, 'Danger Zone'), 140 152 _SettingsTile(
+157
lib/features/settings/presentation/terms_of_service_screen.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_svg/flutter_svg.dart'; 3 + import 'package:lazurite/features/settings/presentation/widgets/contact_section.dart'; 4 + import 'package:url_launcher/url_launcher.dart'; 5 + 6 + class TermsOfServiceScreen extends StatelessWidget { 7 + const TermsOfServiceScreen({super.key}); 8 + 9 + static const _effectiveDate = 'April 15, 2026'; 10 + static const _websiteUrl = 'https://stormlightlabs.org'; 11 + static const _emailUrl = 'mailto:info@stormlightlabs.org'; 12 + 13 + Future<void> _launch(String url) async { 14 + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); 15 + } 16 + 17 + @override 18 + Widget build(BuildContext context) { 19 + final theme = Theme.of(context); 20 + final colorScheme = theme.colorScheme; 21 + final textTheme = theme.textTheme; 22 + 23 + return Scaffold( 24 + appBar: AppBar(title: const Text('Lazurite\'s Terms of Service')), 25 + body: ListView( 26 + padding: const EdgeInsets.all(24), 27 + children: [ 28 + Center( 29 + child: SvgPicture.asset( 30 + 'assets/logo.svg', 31 + width: 64, 32 + height: 64, 33 + colorFilter: ColorFilter.mode(colorScheme.primary, BlendMode.srcIn), 34 + ), 35 + ), 36 + const SizedBox(height: 16), 37 + Text( 38 + 'Terms of Service', 39 + textAlign: TextAlign.center, 40 + style: textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700), 41 + ), 42 + const SizedBox(height: 8), 43 + Text( 44 + 'Effective $_effectiveDate', 45 + textAlign: TextAlign.center, 46 + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 47 + ), 48 + const SizedBox(height: 24), 49 + Text( 50 + 'These Terms govern your use of Lazurite, a Bluesky client built by Stormlight Labs. ' 51 + 'By installing or using Lazurite, you agree to these Terms.', 52 + style: textTheme.bodyLarge, 53 + ), 54 + const SizedBox(height: 20), 55 + const _TermsSection( 56 + title: 'What Lazurite is', 57 + paragraphs: [ 58 + 'Lazurite is client software that helps you access Bluesky from your device.', 59 + 'Lazurite does not provide the Bluesky network itself.', 60 + ], 61 + ), 62 + const _TermsSection( 63 + title: 'Eligibility', 64 + paragraphs: [ 65 + 'You must be legally able to agree to these Terms and meet the minimum age requirement in your jurisdiction.', 66 + ], 67 + ), 68 + const _TermsSection( 69 + title: 'License', 70 + paragraphs: [ 71 + 'We grant you a limited, non-exclusive, non-transferable, revocable license to use Lazurite on devices you control.', 72 + 'You may not misuse the app, break applicable law, or attempt unauthorized access to systems or accounts.', 73 + ], 74 + ), 75 + // TODO: do we link to BlueSky's legalese here? 76 + const _TermsSection( 77 + title: 'Your account and activity', 78 + paragraphs: [ 79 + 'You are responsible for your Bluesky account, credentials, and activity taken through Lazurite.', 80 + 'You retain rights to your content, subject to Bluesky policies and any third-party rights.', 81 + ], 82 + ), 83 + const _TermsSection( 84 + title: 'Acceptable use', 85 + paragraphs: [ 86 + 'Do not use Lazurite to harass others, violate rights, distribute unlawful content, or abuse platform infrastructure.', 87 + 'You are responsible for complying with Bluesky rules and applicable law.', 88 + ], 89 + ), 90 + const _TermsSection( 91 + title: 'Third-party dependencies', 92 + paragraphs: [ 93 + 'Lazurite depends on Bluesky and related third-party services.', 94 + 'If those services change, restrict, or discontinue access, features may degrade or stop working.', 95 + ], 96 + ), 97 + const _TermsSection( 98 + title: 'No warranty', 99 + paragraphs: [ 100 + 'Lazurite is provided "as is" and "as available." We do not guarantee uninterrupted, secure, or error-free operation.', 101 + ], 102 + ), 103 + const _TermsSection( 104 + title: 'Liability', 105 + paragraphs: [ 106 + 'To the maximum extent permitted by law, Stormlight Labs is not liable for indirect, incidental, or consequential damages arising from your use of Lazurite.', 107 + ], 108 + ), 109 + const _TermsSection( 110 + title: 'Changes and termination', 111 + paragraphs: [ 112 + 'We may update, suspend, or discontinue parts of Lazurite.', 113 + 'We may update these Terms. Continued use after updates means you accept the revised Terms.', 114 + ], 115 + ), 116 + ContactSection(onStormlightLabsTap: () => _launch(_websiteUrl), onEmailTap: () => _launch(_emailUrl)), 117 + const SizedBox(height: 12), 118 + Center(child: Text('Lazurite v1.0.0', style: textTheme.bodySmall)), 119 + ], 120 + ), 121 + ); 122 + } 123 + } 124 + 125 + class _TermsSection extends StatelessWidget { 126 + const _TermsSection({required this.title, required this.paragraphs}); 127 + 128 + final String title; 129 + final List<String> paragraphs; 130 + 131 + @override 132 + Widget build(BuildContext context) { 133 + final theme = Theme.of(context); 134 + final colorScheme = theme.colorScheme; 135 + final textTheme = theme.textTheme; 136 + 137 + return Padding( 138 + padding: const EdgeInsets.only(bottom: 18), 139 + child: Column( 140 + crossAxisAlignment: CrossAxisAlignment.start, 141 + children: [ 142 + Text( 143 + title, 144 + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700, color: colorScheme.primary), 145 + ), 146 + const SizedBox(height: 6), 147 + ...paragraphs.map( 148 + (paragraph) => Padding( 149 + padding: const EdgeInsets.only(bottom: 8), 150 + child: Text(paragraph, style: textTheme.bodyMedium), 151 + ), 152 + ), 153 + ], 154 + ), 155 + ); 156 + } 157 + }
+69
lib/features/settings/presentation/widgets/contact_section.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class _ContactLink extends StatelessWidget { 4 + const _ContactLink({required this.pre, required this.label, required this.onTap}); 5 + 6 + final String pre; 7 + final String label; 8 + final VoidCallback onTap; 9 + 10 + @override 11 + Widget build(BuildContext context) { 12 + final theme = Theme.of(context); 13 + final colorScheme = theme.colorScheme; 14 + final textTheme = theme.textTheme; 15 + 16 + return Row( 17 + children: [ 18 + Text(pre, style: textTheme.bodyMedium), 19 + const SizedBox(width: 4), 20 + InkWell( 21 + onTap: onTap, 22 + borderRadius: BorderRadius.circular(8), 23 + child: Padding( 24 + padding: const EdgeInsets.symmetric(vertical: 2), 25 + child: Text( 26 + label, 27 + style: textTheme.bodyMedium?.copyWith( 28 + color: colorScheme.primary, 29 + decoration: TextDecoration.underline, 30 + decorationColor: colorScheme.primary, 31 + ), 32 + ), 33 + ), 34 + ), 35 + ], 36 + ); 37 + } 38 + } 39 + 40 + class ContactSection extends StatelessWidget { 41 + const ContactSection({super.key, required this.onStormlightLabsTap, required this.onEmailTap}); 42 + 43 + final VoidCallback onStormlightLabsTap; 44 + final VoidCallback onEmailTap; 45 + 46 + @override 47 + Widget build(BuildContext context) { 48 + final theme = Theme.of(context); 49 + final colorScheme = theme.colorScheme; 50 + final textTheme = theme.textTheme; 51 + 52 + return Padding( 53 + padding: const EdgeInsets.only(bottom: 18), 54 + child: Column( 55 + crossAxisAlignment: CrossAxisAlignment.start, 56 + children: [ 57 + Text( 58 + 'Contact', 59 + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700, color: colorScheme.primary), 60 + ), 61 + const SizedBox(height: 6), 62 + _ContactLink(pre: 'Visit our website:', label: 'Stormlight Labs', onTap: onStormlightLabsTap), 63 + const SizedBox(height: 6), 64 + _ContactLink(pre: 'Email us at', label: 'info@stormlightlabs.org', onTap: onEmailTap), 65 + ], 66 + ), 67 + ); 68 + } 69 + }
+26
test/core/router/app_router_test.dart
··· 354 354 355 355 router.dispose(); 356 356 }); 357 + 358 + testWidgets('allows unauthenticated access to privacy and terms routes', (tester) async { 359 + currentAuthState = const AuthState.unauthenticated(); 360 + when(() => authBloc.state).thenReturn(currentAuthState); 361 + whenListen(authBloc, Stream<AuthState>.value(currentAuthState), initialState: currentAuthState); 362 + 363 + final router = AppRouter(authBloc: authBloc).router; 364 + 365 + await tester.pumpWidget( 366 + BlocProvider<AuthBloc>.value( 367 + value: authBloc, 368 + child: MaterialApp.router(routerConfig: router), 369 + ), 370 + ); 371 + await tester.pumpAndSettle(); 372 + 373 + router.go('/privacy'); 374 + await tester.pumpAndSettle(); 375 + expect(find.text('Privacy Policy'), findsWidgets); 376 + 377 + router.go('/terms'); 378 + await tester.pumpAndSettle(); 379 + expect(find.text('Terms of Service'), findsWidgets); 380 + 381 + router.dispose(); 382 + }); 357 383 }
+81
test/features/auth/presentation/login_screen_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 + import 'package:lazurite/features/auth/presentation/login_screen.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 11 + 12 + void main() { 13 + late MockAuthBloc authBloc; 14 + 15 + setUp(() { 16 + authBloc = MockAuthBloc(); 17 + when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 18 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); 19 + }); 20 + 21 + Widget buildSubject() { 22 + final router = GoRouter( 23 + routes: [ 24 + GoRoute( 25 + path: '/login', 26 + builder: (context, state) => BlocProvider<AuthBloc>.value(value: authBloc, child: const LoginScreen()), 27 + ), 28 + GoRoute( 29 + path: '/terms', 30 + builder: (context, state) => const Scaffold(body: Text('terms-route')), 31 + ), 32 + GoRoute( 33 + path: '/privacy', 34 + builder: (context, state) => const Scaffold(body: Text('privacy-route')), 35 + ), 36 + ], 37 + initialLocation: '/login', 38 + ); 39 + 40 + return MaterialApp.router(routerConfig: router); 41 + } 42 + 43 + testWidgets('shows terms and privacy links', (tester) async { 44 + await tester.pumpWidget(buildSubject()); 45 + await tester.pumpAndSettle(); 46 + 47 + final scrollable = find.byType(Scrollable).first; 48 + await tester.scrollUntilVisible(find.text('Terms of Service'), 200, scrollable: scrollable); 49 + await tester.scrollUntilVisible(find.text('Privacy Policy'), 200, scrollable: scrollable); 50 + await tester.pumpAndSettle(); 51 + 52 + expect(find.text('Terms of Service'), findsOneWidget); 53 + expect(find.text('Privacy Policy'), findsOneWidget); 54 + }); 55 + 56 + testWidgets('tapping Terms of Service opens terms route', (tester) async { 57 + await tester.pumpWidget(buildSubject()); 58 + await tester.pumpAndSettle(); 59 + 60 + await tester.scrollUntilVisible(find.text('Terms of Service'), 200, scrollable: find.byType(Scrollable).first); 61 + await tester.pumpAndSettle(); 62 + 63 + await tester.tap(find.text('Terms of Service')); 64 + await tester.pumpAndSettle(); 65 + 66 + expect(find.text('terms-route'), findsOneWidget); 67 + }); 68 + 69 + testWidgets('tapping Privacy Policy opens privacy route', (tester) async { 70 + await tester.pumpWidget(buildSubject()); 71 + await tester.pumpAndSettle(); 72 + 73 + await tester.scrollUntilVisible(find.text('Privacy Policy'), 200, scrollable: find.byType(Scrollable).first); 74 + await tester.pumpAndSettle(); 75 + 76 + await tester.tap(find.text('Privacy Policy')); 77 + await tester.pumpAndSettle(); 78 + 79 + expect(find.text('privacy-route'), findsOneWidget); 80 + }); 81 + }
+103
test/features/settings/presentation/legal_screens_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/settings/presentation/privacy_policy_screen.dart'; 4 + import 'package:lazurite/features/settings/presentation/terms_of_service_screen.dart'; 5 + import 'package:plugin_platform_interface/plugin_platform_interface.dart'; 6 + import 'package:url_launcher_platform_interface/link.dart'; 7 + import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; 8 + 9 + class _FakeUrlLauncher extends Fake with MockPlatformInterfaceMixin implements UrlLauncherPlatform { 10 + final List<String> launchedUrls = []; 11 + 12 + @override 13 + LinkDelegate? get linkDelegate => null; 14 + 15 + @override 16 + Future<bool> launchUrl(String url, LaunchOptions options) async { 17 + launchedUrls.add(url); 18 + return true; 19 + } 20 + 21 + @override 22 + Future<bool> supportsMode(PreferredLaunchMode mode) async => true; 23 + 24 + @override 25 + Future<bool> canLaunch(String url) async => true; 26 + } 27 + 28 + void main() { 29 + late _FakeUrlLauncher fakeUrlLauncher; 30 + 31 + setUp(() { 32 + fakeUrlLauncher = _FakeUrlLauncher(); 33 + UrlLauncherPlatform.instance = fakeUrlLauncher; 34 + }); 35 + 36 + group('PrivacyPolicyScreen', () { 37 + testWidgets('renders title and core sections', (tester) async { 38 + await tester.pumpWidget(const MaterialApp(home: PrivacyPolicyScreen())); 39 + await tester.pump(); 40 + 41 + expect(find.text('Privacy Policy'), findsWidgets); 42 + expect(find.text('What the app stores on your device'), findsOneWidget); 43 + expect(find.text('How your data is used'), findsOneWidget); 44 + await tester.scrollUntilVisible(find.text('Contact'), 300); 45 + await tester.pumpAndSettle(); 46 + expect(find.text('Contact'), findsOneWidget); 47 + }); 48 + 49 + testWidgets('contact links launch expected URLs', (tester) async { 50 + await tester.pumpWidget(const MaterialApp(home: PrivacyPolicyScreen())); 51 + await tester.pump(); 52 + 53 + await tester.scrollUntilVisible(find.text('Stormlight Labs'), 300); 54 + await tester.pumpAndSettle(); 55 + 56 + await tester.tap(find.text('Stormlight Labs')); 57 + await tester.pump(); 58 + await tester.tap(find.text('GitHub: stormlightlabs')); 59 + await tester.pump(); 60 + await tester.tap(find.text('info@stormlightlabs.org')); 61 + await tester.pump(); 62 + 63 + expect(fakeUrlLauncher.launchedUrls, contains('https://stormlightlabs.org')); 64 + expect(fakeUrlLauncher.launchedUrls, contains('https://github.com/stormlightlabs')); 65 + expect(fakeUrlLauncher.launchedUrls, contains('mailto:info@stormlightlabs.org')); 66 + }); 67 + }); 68 + 69 + group('TermsOfServiceScreen', () { 70 + testWidgets('renders title and core sections', (tester) async { 71 + await tester.pumpWidget(const MaterialApp(home: TermsOfServiceScreen())); 72 + await tester.pump(); 73 + 74 + expect(find.text('Terms of Service'), findsWidgets); 75 + expect(find.text('What Lazurite is'), findsOneWidget); 76 + await tester.scrollUntilVisible(find.text('Acceptable use'), 300); 77 + await tester.pumpAndSettle(); 78 + expect(find.text('Acceptable use'), findsOneWidget); 79 + await tester.scrollUntilVisible(find.text('Contact'), 300); 80 + await tester.pumpAndSettle(); 81 + expect(find.text('Contact'), findsOneWidget); 82 + }); 83 + 84 + testWidgets('contact links launch expected URLs', (tester) async { 85 + await tester.pumpWidget(const MaterialApp(home: TermsOfServiceScreen())); 86 + await tester.pump(); 87 + 88 + await tester.scrollUntilVisible(find.text('Stormlight Labs'), 300); 89 + await tester.pumpAndSettle(); 90 + 91 + await tester.tap(find.text('Stormlight Labs')); 92 + await tester.pump(); 93 + await tester.tap(find.text('GitHub: stormlightlabs')); 94 + await tester.pump(); 95 + await tester.tap(find.text('info@stormlightlabs.org')); 96 + await tester.pump(); 97 + 98 + expect(fakeUrlLauncher.launchedUrls, contains('https://stormlightlabs.org')); 99 + expect(fakeUrlLauncher.launchedUrls, contains('https://github.com/stormlightlabs')); 100 + expect(fakeUrlLauncher.launchedUrls, contains('mailto:info@stormlightlabs.org')); 101 + }); 102 + }); 103 + }
+45
test/features/settings/presentation/settings_screen_test.dart
··· 94 94 path: '/settings/clean-follows', 95 95 builder: (context, state) => const Scaffold(body: Text('clean-follows')), 96 96 ), 97 + GoRoute( 98 + path: '/terms', 99 + builder: (context, state) => const Scaffold(body: Text('terms-screen')), 100 + ), 101 + GoRoute( 102 + path: '/privacy', 103 + builder: (context, state) => const Scaffold(body: Text('privacy-screen')), 104 + ), 97 105 ], 98 106 ); 99 107 ··· 268 276 expect(find.text('Audit and unfollow problematic accounts in bulk'), findsOneWidget); 269 277 }); 270 278 279 + testWidgets('shows legal rows in About section', (tester) async { 280 + await tester.pumpWidget(buildSubject()); 281 + await tester.pumpAndSettle(); 282 + 283 + await tester.scrollUntilVisible(find.text('Terms of Service'), 300); 284 + await tester.pumpAndSettle(); 285 + 286 + expect(find.text('Terms of Service'), findsOneWidget); 287 + expect(find.text('Privacy Policy'), findsOneWidget); 288 + }); 289 + 271 290 testWidgets('tapping Clean Follows tile navigates to clean follows screen', (tester) async { 272 291 await tester.pumpWidget(buildRoutedSubject()); 273 292 await tester.pumpAndSettle(); ··· 279 298 await tester.pumpAndSettle(); 280 299 281 300 expect(find.text('clean-follows'), findsOneWidget); 301 + }); 302 + 303 + testWidgets('tapping Terms of Service row navigates to terms screen', (tester) async { 304 + await tester.pumpWidget(buildRoutedSubject()); 305 + await tester.pumpAndSettle(); 306 + 307 + await tester.scrollUntilVisible(find.text('Terms of Service'), 300); 308 + await tester.pumpAndSettle(); 309 + 310 + await tester.tap(find.text('Terms of Service')); 311 + await tester.pumpAndSettle(); 312 + 313 + expect(find.text('terms-screen'), findsOneWidget); 314 + }); 315 + 316 + testWidgets('tapping Privacy Policy row navigates to privacy screen', (tester) async { 317 + await tester.pumpWidget(buildRoutedSubject()); 318 + await tester.pumpAndSettle(); 319 + 320 + await tester.scrollUntilVisible(find.text('Privacy Policy'), 300); 321 + await tester.pumpAndSettle(); 322 + 323 + await tester.tap(find.text('Privacy Policy')); 324 + await tester.pumpAndSettle(); 325 + 326 + expect(find.text('privacy-screen'), findsOneWidget); 282 327 }); 283 328 } 284 329
www/image.png

This is a binary file and will not be displayed.

+5 -10
www/index.html
··· 335 335 } 336 336 337 337 .hero-screenshot.desktop-shot { 338 - width: 480px; 338 + width: 640px; 339 339 flex-shrink: 0; 340 340 } 341 341 ··· 566 566 </div> 567 567 568 568 <div class="hero-screenshots"> 569 - <div class="hero-screenshot phone"> 570 - <!-- TODO: replace with actual mobile app screenshot (portrait, ~390x844) --> 571 - <img 572 - src="https://placehold.co/720x1280/191919/7dafff?text=Mobile%0ATimeline&font=inter" 573 - alt="Lazurite mobile timeline screenshot" /> 574 - </div> 569 + 575 570 <div class="hero-screenshot desktop-shot"> 576 571 <!-- TODO: replace with actual desktop app screenshot (landscape, ~1280x800) --> 577 572 <img 578 - src="https://placehold.co/1280x720/0e0e0e/be95ff?text=Desktop%0AWorkspace&font=inter" 573 + src="./image.png" 579 574 alt="Lazurite desktop workspace screenshot" /> 580 575 </div> 581 576 <div class="hero-screenshot phone"> ··· 644 639 <li>PDS browser for exploring repository data</li> 645 640 <li>Composer window for distraction-free posting</li> 646 641 <li>Deep-link handling for <code>at://</code> URIs</li> 647 - <li>Native notifications and global shortcuts</li> 642 + <li>Global shortcuts (post from anywhere)</li> 648 643 </ul> 649 644 <div class="platform-screenshot"> 650 645 <!-- TODO: replace with actual desktop multi-column screenshot (~640x400 crop) --> 651 646 <img 652 - src="https://placehold.co/1280x720/000000/be95ff?text=Multi-Column+Layout&font=inter" 647 + src="./image.png" 653 648 alt="Desktop multi-column layout" /> 654 649 <div class="caption">Multi-column workspace with search</div> 655 650 </div>