From 051eb4d7a6a60643f99c6e0cc7e5279c7b48e11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 7 Sep 2026 00:36:31 +0300 Subject: [PATCH 01/10] Bump the magic ecosystem past the constraint that silently capped it magic_starter alpha.26 needs magic_notifications ^0.2.0, and this app pinned ^0.0.2, which means >=0.0.2 <0.1.0. The two do not intersect, so pub quietly walked the starter back to alpha.24 instead of failing: a fork got a build that resolved, ran, and was two releases stale. The two constraints move together or not at all. alpha.24 also introduced 20 magic_starter.titles.* keys that en.json never gained, and route_service_provider.dart:29 registers the notification routes ungated, so those titles have been rendering as raw keys. The two generated files come along because they are functions of the resolved graph rather than edits: flutter pub get performs a one-time SDK migration on analysis_options.yaml, and the macOS registrant gains purchases_flutter now that magic_payments arrives through the starter. The registrant records the LOCAL graph, which is what keeps the working tree clean. Unlike pubspec.lock, no fork ever consumes this file: any build regenerates it from whatever that fork resolves, so the committed value is transient for them and permanent dirt here would be the only lasting cost. --- analysis_options.yaml | 9 ++++++ assets/lang/en.json | 29 ++++++++++++++++++- macos/Flutter/GeneratedPluginRegistrant.swift | 4 ++- pubspec.yaml | 18 ++++++------ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..bf8d421 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/assets/lang/en.json b/assets/lang/en.json index be2bb06..3c40dfe 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -186,6 +186,28 @@ }, "timezone": { "subtitle": "Set the timezone used for displaying dates and times." + }, + "titles": { + "appearance": "Appearance", + "forgot_password": "Forgot Password", + "language": "Language", + "login": "Sign In", + "newsletter": "Newsletter", + "notification_preferences": "Notification Preferences", + "notifications": "Notifications", + "otp": "Verification Code", + "password": "Password", + "profile": "Profile", + "register": "Create Account", + "reset_password": "Reset Password", + "sessions": "Active Sessions", + "settings": "Settings", + "team_create": "Create Team", + "team_invitation": "Team Invitation", + "team_settings": "Team Settings", + "timezone": "Timezone", + "two_factor": "Two-Factor Authentication", + "two_factor_challenge": "Two-Factor Challenge" } }, "nav": { @@ -208,7 +230,12 @@ "no_preferences": "No notification preferences available.", "channel_email": "Email", "channel_in_app": "In-App", - "channel_push": "Push" + "channel_push": "Push", + "channel_sms": "SMS", + "delete": "Delete notification", + "delete_confirm_message": "This notification will be permanently removed.", + "delete_confirm_title": "Delete notification?", + "delete_failed": "Failed to delete notification. Please try again." }, "profile": { "browser_sessions": "Browser Sessions", diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 111bae6..744ed3d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,13 @@ import Foundation import app_links import desktop_webview_window -import file_picker +import file_picker_darwin import file_selector_macos import flutter_secure_storage_darwin import flutter_timezone import flutter_web_auth_2 import google_sign_in_ios +import purchases_flutter import share_plus import shared_preferences_foundation import url_launcher_macos @@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.yaml b/pubspec.yaml index f1cac1c..f03ed6c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,21 +35,21 @@ dependencies: # copied outside this workspace resolves without any sibling checkout. # In-workspace development overrides these to local path checkouts via the # gitignored pubspec_overrides.yaml; that file never ships in the fork. - magic: ^0.0.6 - magic_deeplink: ^0.0.2 - magic_notifications: ^0.0.2 - magic_social_auth: ^0.0.2 - magic_starter: ^0.0.1-alpha.17 + magic: ^0.0.9 + magic_deeplink: ^0.0.3 + magic_notifications: ^0.2.0 + magic_social_auth: ^0.0.3 + magic_starter: ^0.0.1-alpha.26 # Dev-tooling, imported by lib/main.dart under kDebugMode (release tree-shakes). - magic_devtools: ^0.0.2 - fluttersdk_dusk: ^0.0.9 - fluttersdk_telescope: ^0.0.4 + magic_devtools: ^0.0.4 + fluttersdk_dusk: ^0.0.13 + fluttersdk_telescope: ^0.0.5 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - fluttersdk_artisan: ^0.0.9 + fluttersdk_artisan: ^0.0.14 # Pin as a real dependency, not a workspace-only override: magic's own # file_picker constraint (">=11.0.2 <12.0.0-0") already excludes the From 376d4b0cba01f29969aee80401c4fb1e81a984b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 7 Sep 2026 00:56:37 +0300 Subject: [PATCH 02/10] Wire the two plugins that were registered but never configured config/app.dart registers LocalizationServiceProvider and NotificationServiceProvider, but neither had a config file and neither appeared in main.dart's factory list, so both fell through to package defaults. The notifications feature flag was off besides, which meant a dependency this app pays for shipped undemonstrated. supported_locales already claimed en and tr while assets/lang held only en.json. That claim is now true. The parity between the two files is exact rather than best effort because magic's Translator replaces its sentence map on load instead of merging it with the fallback: a key missing from tr.json renders the raw key path on screen, not the English string. locale_parity_test.dart is what keeps that from drifting back. auto_detect_locale stays off. A device set to a locale this boilerplate does not ship has no tested path here, and the replace-not-merge behaviour above is exactly what turns that into raw keys on every screen. A fork flips it once its supported_locales covers the locales its users actually have. --- assets/lang/tr.json | 348 ++++++++++++++++++++++++++++ lib/config/localization.dart | 57 +++++ lib/config/magic_starter.dart | 3 +- lib/config/notifications.dart | 74 ++++++ lib/main.dart | 4 + pubspec.yaml | 1 + test/config/locale_parity_test.dart | 59 +++++ 7 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 assets/lang/tr.json create mode 100644 lib/config/localization.dart create mode 100644 lib/config/notifications.dart create mode 100644 test/config/locale_parity_test.dart diff --git a/assets/lang/tr.json b/assets/lang/tr.json new file mode 100644 index 0000000..afca553 --- /dev/null +++ b/assets/lang/tr.json @@ -0,0 +1,348 @@ +{ + "common": { + "welcome": "Hoş geldiniz: ", + "loading": "Yükleniyor...", + "save": "Kaydet", + "cancel": "İptal", + "confirm": "Onayla", + "delete": "Sil", + "edit": "Düzenle", + "go_to_dashboard": "Panele Git", + "off": "Kapalı", + "on": "Açık", + "remove": "Kaldır", + "toggle_theme": "Temayı değiştir", + "unknown": "Bilinmiyor", + "upload": "Yükle", + "user": "Kullanıcı", + "done": "Tamam", + "error_occurred": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + "page_of": ":current / :total", + "upgrade": "Yükselt", + "upgrade_available_on": ":plan ve üzeri planlarda kullanılabilir.", + "upgrade_dialog_not_now": "Şimdi değil" + }, + "validation": { + "required": ":attribute alanı zorunludur.", + "email": ":attribute geçerli bir e-posta adresi olmalıdır.", + "min": ":attribute en az :min karakter olmalıdır.", + "max": ":attribute en fazla :max karakter olabilir.", + "confirmed": ":attribute onayı eşleşmiyor.", + "accepted": ":attribute kabul edilmelidir.", + "same": ":attribute ve :other eşleşmelidir." + }, + "app": { + "name": "Uygulamam" + }, + "attributes": { + "current_password": "Mevcut Şifre", + "email": "E-posta Adresi", + "name": "Ad", + "new_password": "Yeni Şifre", + "password": "Şifre", + "password_confirmation": "Şifreyi Onayla", + "phone": "Telefon Numarası", + "phone_country": "Ülke Kodu", + "role": "Rol", + "timezone": "Saat Dilimi" + }, + "auth": { + "already_have_account": "Zaten bir hesabınız var mı?", + "authentication_code": "Kimlik Doğrulama Kodu", + "back_to_login": "Girişe Dön", + "challenge_failed": "İki faktörlü doğrulama başarısız oldu. Lütfen tekrar deneyin.", + "dont_have_account": "Hesabınız yok mu?", + "forgot_password": "Şifrenizi mi unuttunuz?", + "forgot_password_subtitle": "Sıfırlama bağlantısı almak için e-postanızı girin", + "forgot_password_title": "Şifremi Unuttum", + "invalid_response": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + "login_failed": "Giriş başarısız oldu. Lütfen bilgilerinizi kontrol edin.", + "login_subtitle": "Hesabınıza giriş yapın", + "login_title": "Giriş Yap", + "logout": "Çıkış Yap", + "or_continue_with": "Veya şununla devam et", + "password_reset_failed": "Şifre sıfırlanamadı. Lütfen tekrar deneyin.", + "password_reset_success": "Şifreniz sıfırlandı. Artık giriş yapabilirsiniz.", + "profile": "Profil", + "recovery_code": "Kurtarma Kodu", + "register_failed": "Kayıt başarısız oldu. Lütfen tekrar deneyin.", + "register_subtitle": "Başlamak için hesabınızı oluşturun", + "register_title": "Hesap Oluştur", + "remember_me": "Beni hatırla", + "reset_link_failed": "Sıfırlama bağlantısı gönderilemedi. Lütfen tekrar deneyin.", + "reset_link_sent": "E-postanıza bir şifre sıfırlama bağlantısı gönderildi.", + "reset_password_button": "Şifreyi Sıfırla", + "reset_password_subtitle": "Yeni şifrenizi girin", + "reset_password_title": "Şifreyi Sıfırla", + "send_reset_link": "Sıfırlama Bağlantısı Gönder", + "sign_in": "Giriş Yap", + "sign_in_with": ":provider ile giriş yap", + "sign_up": "Kaydol", + "sign_up_with": ":provider ile kaydol", + "signed_in_as": "Şu hesapla giriş yapıldı", + "two_factor_challenge": "İki Faktörlü Doğrulama", + "two_factor_code_description": "Kimlik doğrulama uygulamanızdaki kodu girerek hesabınıza erişimi onaylayın.", + "two_factor_recovery_description": "Acil durum kurtarma kodlarınızdan birini girerek hesabınıza erişimi onaylayın.", + "use_authentication_code": "Kimlik doğrulama kodu kullan", + "use_recovery_code": "Kurtarma kodu kullan", + "verify": "Doğrula", + "agree_to_legal": "Hesap oluşturarak şunları kabul etmiş olursunuz:", + "terms_of_service": "Kullanım Koşulları", + "privacy_policy": "Gizlilik Politikası", + "legal_and": "ve" + }, + "errors": { + "unexpected": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + "network_error": "Ağ bağlantısı başarısız oldu. Lütfen internet bağlantınızı kontrol edip tekrar deneyin.", + "network_timeout": "İstek zaman aşımına uğradı. Lütfen daha sonra tekrar deneyin." + }, + "fields": { + "email_placeholder": "siz@ornek.com", + "name_placeholder": "Adınızı ve soyadınızı girin", + "password_confirmation_placeholder": "Şifrenizi tekrar girin", + "password_placeholder": "En az 8 karakter", + "phone_country_placeholder": "Ülke kodu seçin", + "phone_placeholder": "+905301234567", + "otp_placeholder": "123456" + }, + "magic_starter": { + "auth": { + "continue_as_guest": "Misafir Olarak Devam Et", + "guest_login_error": "Misafir olarak devam edilemedi. Lütfen tekrar deneyin." + }, + "email_verification": { + "resend_button": "Doğrulama E-postasını Yeniden Gönder", + "section_title": "E-posta Doğrulama", + "send_error": "Doğrulama e-postası gönderilemedi. Lütfen tekrar deneyin.", + "sent": "Doğrulama e-postası başarıyla gönderildi.", + "unverified_description": "E-posta adresiniz henüz doğrulanmadı. Hesabınızı güvence altına almak için lütfen doğrulayın.", + "unverified_title": "E-posta adresi doğrulanmadı", + "verified": "E-posta adresiniz doğrulandı." + }, + "guest_upgrade": { + "button": "Hesabı Yükselt", + "description": "Verilerinizi korumak ve tüm özelliklerin kilidini açmak için kalıcı bir hesap oluşturun.", + "title": "Misafir Hesabınızı Yükseltin" + }, + "nav": { + "settings": "Ayarlar" + }, + "notifications": { + "fetch_error": "Bildirim tercihleri yüklenemedi.", + "preferences_title": "Bildirim Tercihleri" + }, + "newsletter": { + "fetch_error": "Bülten tercihleri yüklenemedi.", + "section_description": "Ürün güncellemelerini ve duyuruları e-posta ile alın.", + "section_title": "Bülten Tercihleri", + "subscribe_label": "Ürün güncellemelerine ve haberlere abone ol", + "subscribed_status": "Şu anda abonesiniz.", + "toggle_button": "Tercihleri Kaydet", + "toggle_label": "Bülten Al", + "unsubscribed_status": "Abone değilsiniz.", + "update_error": "Bülten tercihleri güncellenemedi." + }, + "otp": { + "back_button": "Geri", + "code_label": "Doğrulama Kodu", + "code_subtitle": "Telefon numaranıza gönderilen doğrulama kodunu girin.", + "code_title": "Telefon Numarasını Doğrula", + "phone_subtitle": "Doğrulama kodu almak için telefon numaranızı girin.", + "phone_title": "Telefon Doğrulama", + "resend_link": "Kodu yeniden gönder", + "send_code_button": "Kod Gönder", + "send_error": "Doğrulama kodu gönderilemedi. Lütfen tekrar deneyin.", + "verify_button": "Kodu Doğrula", + "verify_error": "Kod doğrulanamadı. Lütfen tekrar deneyin." + }, + "appearance": { + "dark": "Koyu", + "dark_description": "Her zaman koyu temayı kullan.", + "light": "Açık", + "light_description": "Her zaman açık temayı kullan.", + "section_footer": "Sistem seçeneği cihazınızın görünüm ayarlarını izler.", + "subtitle": "Tercih ettiğiniz renk şemasını seçin.", + "system": "Sistem", + "system_description": "Sistem görünüm ayarını izle.", + "title": "Görünüm" + }, + "language": { + "subtitle": "Uygulama genelinde kullanılan dili seçin." + }, + "profile": { + "delete_account": { + "button": "Hesabı Sil", + "description": "Hesabınızı ve ilişkili tüm verilerinizi kalıcı olarak silin. Bu işlem geri alınamaz.", + "guest_upgrade_description": "Verilerinizi yönetmek veya silmek için misafir hesabınızı tam bir hesaba dönüştürün.", + "guest_upgrade_title": "Hesap Yükseltmesi Gerekli", + "password_label": "Şifrenizi onaylayın", + "title": "Hesabı Sil" + } + }, + "settings": { + "account_section": "Hesap", + "preferences_section": "Tercihler", + "security_section": "Güvenlik" + }, + "timezone": { + "subtitle": "Tarih ve saatlerin görüntülenmesinde kullanılan saat dilimini ayarlayın." + }, + "titles": { + "appearance": "Görünüm", + "forgot_password": "Şifremi unuttum", + "language": "Dil", + "login": "Giriş yap", + "newsletter": "Bülten", + "notification_preferences": "Bildirim tercihleri", + "notifications": "Bildirimler", + "otp": "Doğrulama kodu", + "password": "Şifre", + "profile": "Profil", + "register": "Hesap oluştur", + "reset_password": "Şifre sıfırla", + "sessions": "Aktif oturumlar", + "settings": "Ayarlar", + "team_create": "Takım oluştur", + "team_invitation": "Takım daveti", + "team_settings": "Takım ayarları", + "timezone": "Saat dilimi", + "two_factor": "İki adımlı doğrulama", + "two_factor_challenge": "Doğrulama adımı" + } + }, + "nav": { + "dashboard": "Panel", + "profile": "Profil", + "settings": "Ayarlar", + "system": "Sistem" + }, + "notifications": { + "empty": "Bildirim yok", + "list_subtitle": "Bildirimlerinizi görüntüleyin ve yönetin", + "load_failed": "Bildirimler yüklenemedi", + "mark_all_read": "Tümünü okundu olarak işaretle", + "preferences_description": "Bildirimleri nasıl ve ne zaman alacağınızı yönetin", + "preferences_title": "Bildirim Tercihleri", + "settings": "Bildirim Ayarları", + "title": "Bildirimler", + "view_all": "Tüm bildirimleri görüntüle", + "badge_overflow": "9+", + "no_preferences": "Kullanılabilir bildirim tercihi yok.", + "channel_email": "E-posta", + "channel_in_app": "Uygulama İçi", + "channel_push": "Anlık Bildirim", + "channel_sms": "SMS", + "delete": "Bildirimi sil", + "delete_confirm_message": "Bu bildirim kalıcı olarak silinecek.", + "delete_confirm_title": "Bildirim silinsin mi?", + "delete_failed": "Bildirim silinemedi. Lütfen tekrar deneyin." + }, + "profile": { + "browser_sessions": "Tarayıcı Oturumları", + "browser_sessions_description": "Diğer tarayıcı ve cihazlardaki etkin oturumlarınızı yönetin ve çıkış yapın.", + "confirm_password": "Şifreyi Onayla", + "confirm_password_description": "Devam etmeden önce lütfen şifrenizi onaylayın.", + "copy_recovery_codes": "Kurtarma Kodlarını Kopyala", + "current_device": "Mevcut cihaz", + "delete_failed": "Hesap silinemedi. Lütfen tekrar deneyin.", + "extended_information": "Genişletilmiş Bilgiler", + "language_label": "Dil", + "logout_other_sessions": "Diğer Tarayıcı Oturumlarından Çıkış Yap", + "no_active_sessions": "Etkin oturum bulunamadı.", + "other_sessions_revoke_error": "Diğer tarayıcı oturumları iptal edilemedi.", + "password_update_failed": "Şifre güncellenemedi. Lütfen mevcut şifrenizi kontrol edin.", + "password_updated": "Şifre başarıyla güncellendi.", + "phone_country_label": "Ülke Kodu", + "phone_label": "Telefon Numarası", + "photo_delete_failed": "Profil fotoğrafı kaldırılamadı.", + "photo_deleted": "Profil fotoğrafı kaldırıldı.", + "photo_requirements": "JPG, GIF veya PNG. En fazla 1MB.", + "photo_update_failed": "Profil fotoğrafı güncellenemedi.", + "photo_updated": "Profil fotoğrafı başarıyla güncellendi.", + "profile_information": "Profil Bilgileri", + "profile_photo": "Profil Fotoğrafı", + "revoke": "İptal Et", + "session_revoke_error": "Tarayıcı oturumu iptal edilemedi.", + "sessions_fetch_error": "Tarayıcı oturumları yüklenemedi.", + "settings": "Profil Ayarları", + "settings_subtitle": "Hesap bilgilerinizi ve tercihlerinizi yönetin", + "timezone_label": "Saat Dilimi", + "timezone_search": "Saat dilimi ara", + "timezone_select": "Saat dilimi seç", + "two_factor_authentication": "İki Faktörlü Doğrulama", + "two_factor_code_label": "Kimlik Doğrulama Kodu", + "two_factor_code_placeholder": "6 haneli kodu girin", + "two_factor_confirm": "Onayla", + "two_factor_confirm_failed": "İki faktörlü doğrulama onaylanamadı.", + "two_factor_disable": "Devre Dışı Bırak", + "two_factor_disable_failed": "İki faktörlü doğrulama devre dışı bırakılamadı.", + "two_factor_disabled_description": "İki faktörlü doğrulama hesabınız için şu anda devre dışı.", + "two_factor_enable": "Etkinleştir", + "two_factor_enable_failed": "İki faktörlü doğrulama etkinleştirilemedi.", + "two_factor_enabled": "İki faktörlü doğrulama etkinleştirildi.", + "two_factor_enabled_description": "Hesabınız iki faktörlü doğrulama ile korunuyor.", + "two_factor_manual_entry": "Manuel giriş anahtarı", + "two_factor_recovery_codes_description": "Bu kurtarma kodlarını güvenli bir yerde saklayın. Kimlik doğrulama cihazınızı kaybederseniz hesabınıza erişmek için kullanılabilirler.", + "two_factor_recovery_codes_fetch_failed": "Kurtarma kodları yüklenemedi.", + "two_factor_recovery_codes_regenerate_failed": "Kurtarma kodları yeniden oluşturulamadı.", + "two_factor_regenerate_codes": "Kurtarma Kodlarını Yeniden Oluştur", + "two_factor_setup_description": "Kimlik doğrulama uygulamanızla QR kodunu tarayın ve kurulumu onaylamak için oluşturulan kodu girin.", + "two_factor_show_recovery_codes": "Kurtarma Kodlarını Göster", + "update_failed": "Profil güncellenemedi. Lütfen tekrar deneyin.", + "update_password": "Şifreyi Güncelle", + "updated": "Profil başarıyla güncellendi.", + "copy_recovery_codes_success": "Kurtarma kodları panoya kopyalandı.", + "two_factor_auth": "İki Faktörlü Doğrulama", + "two_factor": { + "copy_codes": "Tüm Kodları Kopyala", + "invalid_code": "Girilen iki faktörlü doğrulama kodu geçersizdi." + } + }, + "teams": { + "accept_invitation": "Daveti Kabul Et", + "accept_invitation_subtitle": "Bu daveti kabul ederek bir takıma katılın", + "accept_invite_failed": "Davet kabul edilemedi.", + "cancel_invite_failed": "Davet iptal edilemedi.", + "cancel_invite_label": "Daveti İptal Et", + "confirm_cancel_invite": "Bu daveti iptal etmek istediğinizden emin misiniz?", + "confirm_remove_member": "Bu üyeyi takımdan kaldırmak istediğinizden emin misiniz?", + "create_failed": "Takım oluşturulamadı.", + "create_team": "Yeni Takım Oluştur", + "create_team_subtitle": "Başkalarıyla iş birliği yapmak için yeni bir takım oluşturun", + "created": "Takım başarıyla oluşturuldu.", + "current_members": "Mevcut Üyeler", + "feature_disabled": "Takım özellikleri bu uygulama için etkinleştirilmemiş.", + "general_settings": "Genel", + "invite_accepted": "Takıma başarıyla katıldınız.", + "invite_canceled": "Davet başarıyla iptal edildi.", + "invite_failed": "Davet gönderilemedi.", + "invite_member": "Üye Davet Et", + "invite_sent": "Davet başarıyla gönderildi.", + "member_remove_failed": "Üye kaldırılamadı.", + "member_removed": "Üye takımdan kaldırıldı", + "no_invitations": "Bekleyen davet yok", + "no_members": "Henüz takım üyesi yok", + "no_team_selected": "Takım seçilmedi.", + "pending": "Beklemede", + "pending_invitations": "Bekleyen Davetler", + "remove_member_label": "Üyeyi Kaldır", + "role_admin": "Yönetici", + "role_member": "Üye", + "select_team": "Bir takım seçin", + "send_invite": "Davet Gönder", + "settings": "Takım Ayarları", + "settings_subtitle": "Takım adınızı, üyelerinizi ve davetlerinizi yönetin", + "switch_failed": "Takım değiştirilemedi.", + "team": "Takım", + "team_name": "Takım Adı", + "update_failed": "Takım güncellenemedi.", + "updated": "Takım başarıyla güncellendi." + }, + "time": { + "days_ago": ":days gün önce", + "hours_ago": ":hours sa önce", + "just_now": "Az önce", + "minutes_ago": ":minutes dk önce", + "date_format": ":day/:month/:year" + } +} diff --git a/lib/config/localization.dart b/lib/config/localization.dart new file mode 100644 index 0000000..8099812 --- /dev/null +++ b/lib/config/localization.dart @@ -0,0 +1,57 @@ +import 'package:magic/magic.dart'; + +/// Localization configuration. +/// +/// Wires `LocalizationServiceProvider`, which reads only `localization.*` (see +/// its own docblock). Without this file every key the provider reads falls +/// through to its built-in default of `en`, and `assets/lang/tr.json` is never +/// loaded even though `magic_starter.supported_locales` already lists `tr` +/// (see `lib/config/magic_starter.dart`); this file is what makes that list +/// true. See: https://magic.fluttersdk.com/docs/localization +Map get localizationConfig => { + 'localization': { + /// The default locale for the application. + 'locale': env('APP_LOCALE', 'en'), + + /// The fallback locale when a translation is not found. magic's + /// `Translator` REPLACES its sentence map on load rather than merging it + /// with the fallback, so a partial `tr.json` would surface as raw keys + /// (`profile.settings`) rather than English prose; `tr.json` is kept a + /// complete mirror of `en.json`'s key set for exactly this reason. + 'fallback_locale': 'en', + + /// List of supported locales. Add a locale here only once its JSON file + /// under `assets/lang/` covers every key `en.json` does; the translator's + /// replace-not-merge behavior above makes a partial file worse than + /// leaving the locale out. + 'supported_locales': ['en', 'tr'], + + /// Auto-detect locale from device/browser on app start. + /// + /// Off, deliberately, and not because auto-detection is wrong: a device + /// set to a locale this boilerplate does not ship (`de_DE`, `fr_FR`) has + /// no tested path here, and the translator's replace-not-merge sentence + /// map is exactly the mechanism that turns an untested locale into raw + /// keys on every screen. Flip this on once a fork's `supported_locales` + /// covers every locale its users actually have. + 'auto_detect_locale': false, + + /// Path to translation JSON files, matching the `assets:` entry in + /// `pubspec.yaml`. + 'path': 'assets/lang', + + /// Default IANA timezone for date operations, used when detection is + /// disabled or fails. + 'timezone': 'UTC', + + /// Auto-detect timezone from device on app start. Magic reads the real + /// platform IANA identifier (through `flutter_timezone`) and its + /// `LocalizationServiceProvider` boots `DateManager` itself, so nothing + /// needs feeding in by hand. When no valid zone resolves, the `timezone` + /// above stays in effect rather than a guess. + 'auto_detect_timezone': true, + + /// Default date format pattern. + 'date_format': 'MMMM do yyyy', + }, +}; diff --git a/lib/config/magic_starter.dart b/lib/config/magic_starter.dart index 9bad64f..4ddd6fe 100644 --- a/lib/config/magic_starter.dart +++ b/lib/config/magic_starter.dart @@ -15,10 +15,11 @@ Map get magicStarterConfig => { 'sessions': true, 'phone_otp': false, 'newsletter': false, - 'notifications': false, + 'notifications': true, 'email_verification': true, 'guest_auth': false, 'timezones': false, + 'billing': false, }, 'auth': {'email': true, 'phone': false}, 'defaults': {'locale': 'en', 'timezone': 'UTC'}, diff --git a/lib/config/notifications.dart b/lib/config/notifications.dart new file mode 100644 index 0000000..7339ccd --- /dev/null +++ b/lib/config/notifications.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:magic/magic.dart'; + +/// Notifications configuration. +/// +/// Wires `magic_notifications`' `NotificationServiceProvider`, which reads +/// only `notifications.*` (see its own docblock). An absent push driver or +/// app id is a supported build (push stays quiet, the database channel still +/// works), so both read through [env] with an empty-string fallback rather +/// than requiring a `.env` entry. +/// See: https://magic.fluttersdk.com/docs/notifications +Map get notificationsConfig => { + 'notifications': { + 'push': { + // The only driver `magic_notifications` ships. Read through [env] + // rather than hardcoded so a consumer that registers a driver of its + // own with `Notify.extend` can select it without a code change here. + 'driver': env('NOTIFICATIONS_PUSH_DRIVER', 'onesignal'), + + // Public by design: the Web SDK needs this client-side to open its own + // socket, and it carries no send capability. The OneSignal REST API + // key that CAN send notifications is server-only and never belongs in + // a bundled `.env`. Empty by default; a fork adds its own app id. + 'app_id': env('ONESIGNAL_APP_ID', ''), + + 'notify_button_enabled': false, + + // ---------------------------------------------------------------------- + // The permission posture: ask once where a gesture already justifies it, + // never spend the one-shot browser prompt on an unprompted page load. + // ---------------------------------------------------------------------- + + // Ask on login, but only where the ask is a dialog somebody expects. + // + // The package raises the OS request once per launch when an identity is + // declared, and on mobile that is honest: a person signing in expects to + // be asked, and the platform renders the dialog directly behind the + // sign-in that explains what it is for. + // + // On the web the same call either does nothing or does harm. MDN's + // "Using the Notifications API" guide: browsers disallow a permission + // request that is not triggered by a user gesture, so an automatic + // request at login either goes nowhere (Firefox, Safari) or shows a + // system prompt the operator did not ask for (Chrome), and a dismissal + // there pushes the origin toward being blocked outright. `denied` is a + // state no code can recover, so this stays off on web and leans on the + // in-app reminder below, whose button is a real gesture instead. + 'auto_request_on_login': !kIsWeb, + + // How often the in-app reminder may re-ask a device that declined. + // + // `0` and an absent key both mean NEVER, which is a real default rather + // than a neutral one: a device that never gets asked again never comes + // back. 20 rather than 24 walks the reminder across the day instead of + // pinning it to the moment the user was already busy enough to decline. + 'reprompt_after_hours': 20, + + // Keep the route back on a device the OS prompt is spent on. Doubles as + // `canOpenPlatformSettings`, turning a blocked permission into a control + // on iOS and Android rather than a dead end. + 'fallback_to_settings': true, + }, + 'database': { + 'enabled': true, + 'polling_interval': 30, // seconds + }, + 'soft_prompt': { + // The app's own reminder, and on the web the ONLY thing that ever asks: + // `auto_request_on_login` is off there, so switching this off would mean + // a browser is never asked for push at all. + 'enabled': true, + }, + }, +}; diff --git a/lib/main.dart b/lib/main.dart index 9515c45..425b677 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,8 @@ import 'config/cache.dart'; import 'config/logging.dart'; import 'config/broadcasting.dart'; import 'config/deeplink.dart'; +import 'config/localization.dart'; +import 'config/notifications.dart'; import 'config/wind_theme.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:magic_devtools/magic_devtools.dart'; @@ -37,6 +39,8 @@ void main() async { () => loggingConfig, () => broadcastingConfig, () => deeplinkConfig, + () => localizationConfig, + () => notificationsConfig, () => magicStarterConfig, ], ); diff --git a/pubspec.yaml b/pubspec.yaml index f03ed6c..2306aac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -77,6 +77,7 @@ flutter: assets: - assets/lang/en.json + - assets/lang/tr.json # Runtime env. Must be bundled so flutter_dotenv can load it on web; # without this the app silently falls back to Env defaults # (BROADCAST_CONNECTION=null) and realtime never connects. `.env` is diff --git a/test/config/locale_parity_test.dart b/test/config/locale_parity_test.dart new file mode 100644 index 0000000..f974de2 --- /dev/null +++ b/test/config/locale_parity_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Guards the invariant that every shipped locale carries the SAME key set. +/// +/// magic's `Translator` REPLACES its sentence map on load rather than merging +/// it with the fallback, so a key present in `en.json` and missing from +/// `tr.json` does not fall back to English: it renders the raw key path +/// (`magic_starter.titles.login`) on screen. That failure is silent at build +/// time and only visible by driving the app in the missing locale, which is +/// why it gets a test rather than a review habit. +/// +/// The check is exact equality, not a subset, because the reverse direction is +/// just as wrong: a key only `tr.json` has is dead weight that no English +/// screen can ever reach, and it usually means a translation was copied from a +/// product app that has a feature this one does not. +void main() { + Set flatten(Map node, [String prefix = '']) { + final Set keys = {}; + node.forEach((String key, dynamic value) { + keys.add('$prefix$key'); + if (value is Map) { + keys.addAll(flatten(value, '$prefix$key.')); + } + }); + return keys; + } + + Set keysOf(String locale) { + final File file = File('assets/lang/$locale.json'); + return flatten(jsonDecode(file.readAsStringSync()) as Map); + } + + test('every supported locale carries the same key set as en', () { + // Mirrors `localization.supported_locales` in lib/config/localization.dart. + // Adding a locale there without adding it here leaves the new file + // unguarded, which is the state this test exists to end. + const List locales = ['tr']; + + final Set english = keysOf('en'); + expect(english, isNotEmpty, reason: 'assets/lang/en.json parsed empty'); + + for (final String locale in locales) { + final Set translated = keysOf(locale); + expect( + translated.difference(english), + isEmpty, + reason: '$locale.json has keys en.json does not', + ); + expect( + english.difference(translated), + isEmpty, + reason: '$locale.json is missing keys, which render as raw key paths', + ); + } + }); +} From f0023a1f2160572de1378425a6408c4b2bd7903b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 7 Sep 2026 01:10:00 +0300 Subject: [PATCH 03/10] Close the guardrail gaps a fork would inherit Four independent things, all of which were wrong or missing today. CI gains a published job. Resolving is not the check: pub exits 0 on a downgrade as long as SOME version in range resolves, which is exactly how the alpha.24 walk-back stayed green. The job reads the resolved version out of the lock and asserts it, verified against a synthetic alpha.24 lock to confirm it fails on the input it exists to catch. The component-visual-reviewer agent pointed at /Users/anilcan/Code/fluttersdk/ lib/ui/components, missing this repo's own directory segment. That path does not exist, so every find and grep in its procedure returned nothing and reported a clean review. It was also the only absolute path in a repository whose whole purpose is to be cloned somewhere else. design.md described components as living in the magic_starter generic library on one line and scoped the WindRecipe import rule to magic_starter files on another. Both meant this app's own lib/ui/components/. The import rule itself was right and is unchanged. design-tokens rescanned raw on one trigger, an unterminated block comment at end of file. That catches the OPEN case, which flutter analyze already makes impossible in real Dart. The CLOSED case is the live one: a /* inside a triple-quoted string that some later ordinary */ closes leaves the scanner balanced at EOF, so nothing fires and the region between them is dropped in silence. Measured against the pre-edit script, a raw literal placed that way passed clean. --- .claude/agents/component-visual-reviewer.md | 37 +++++++++++------ .claude/rules/design.md | 4 +- .claude/rules/flutter-app.md | 35 ++++++++++++++++ .github/workflows/ci.yml | 34 ++++++++++++++++ AGENTS.md | 8 +++- bin/design-tokens | 44 ++++++++++++++------- docs/verification-loop.md | 44 +++++++++++++++++++++ 7 files changed, 177 insertions(+), 29 deletions(-) create mode 100644 .claude/rules/flutter-app.md diff --git a/.claude/agents/component-visual-reviewer.md b/.claude/agents/component-visual-reviewer.md index 5cdf522..eb2cf3f 100644 --- a/.claude/agents/component-visual-reviewer.md +++ b/.claude/agents/component-visual-reviewer.md @@ -25,14 +25,23 @@ You receive: ## PROCESS -### 1. Read DESIGN.md +### 1. Load the design system from disk, before looking at anything -Read the `design_md` file to load: -- The `colors` section: light/dark hex values for every semantic token role. -- The `typography` section: font family, sizes, weights, line-heights. -- The `rounded` section: corner radius values. -- The `spacing` section: scale values. -- The `components` section: token bindings for specific components. +**Every expected value comes from a file you read in this step, never from memory and never from an +example in this document.** Where a constant quoted here disagrees with a file you read, THE FILE +WINS and the constant is stale: say so in your output, because it means this reviewer needs updating. + +Read, all of them, from the repository root: + +| File | What it gives you | +|---|---| +| `DESIGN.md` (the `design_md` argument) | the `colors`, `typography`, `rounded`, and `spacing` sections; the light/dark hex per role, type scale, radii, spacing. Then the body, which carries the component conventions and the deliberate exceptions | +| `lib/config/wind_theme.g.dart` | what `design:sync` actually emitted. `DESIGN.md` may declare a token this table does not carry, and a declared-but-unemitted token silently does nothing | +| `lib/config/wind_theme.dart` | the `supplementAliases` map, the hand-authored token families `design:sync` does not generate (see DESIGN.md's "Custom token families" section) | +| `.claude/rules/design.md` | the 17-token alias table and the anti-pattern table. Every row is a measured defect that already shipped here, and it is the highest-value part of your checklist | + +A hex you cannot find in `wind_theme.g.dart` is probably legitimate and probably in the supplement. +A hex you cannot find in either file is a violation. ### 2. Read the screenshots @@ -47,19 +56,25 @@ Read both screenshots visually. Identify: ### 3. Check the component source (optional but preferred) -If the component source is accessible, read it to confirm token usage: +If the component source is accessible, read it to confirm token usage. Paths are relative to the +repository root, which is the magic_example project itself: ```bash -find /Users/anilcan/Code/fluttersdk/lib/ui/components -name "*.dart" | xargs grep -l "" +find lib/ui/components -name "*.dart" | xargs grep -l "" ``` Look for raw `Color(0xFF...)`, `Colors.*`, or hardcoded pixel margins that indicate a token bypass. ```bash -grep -rn "Color(0x\|Colors\." /Users/anilcan/Code/fluttersdk/lib/ui/components// -grep -rn "SizedBox(height: [0-9]\|SizedBox(width: [0-9]" /Users/anilcan/Code/fluttersdk/lib/ui/components// +grep -rn "Color(0x\|Colors\." lib/ui/components// +grep -rn "SizedBox(height: [0-9]\|SizedBox(width: [0-9]" lib/ui/components// ``` +An earlier version of this file hardcoded an absolute path one segment short of the project (missing +the `magic_example/` segment). That directory did not exist, so the grep matched nothing and every +review silently passed this step. If a command here returns nothing, confirm the path resolves before +concluding the component is clean. + --- ## SCORING DIMENSIONS diff --git a/.claude/rules/design.md b/.claude/rules/design.md index d6108dd..c9e3d34 100644 --- a/.claude/rules/design.md +++ b/.claude/rules/design.md @@ -9,7 +9,7 @@ These rules apply whenever you touch any file under `lib/`. They complement `CLA ## Atomic Component Folder Contract -Every component in the `magic_starter` generic library lives in a 4-file atomic folder: +Every component in this app's own `lib/ui/components/` library lives in a 4-file atomic folder: ``` lib/ui/components// @@ -52,7 +52,7 @@ final myRecipe = WindRecipe( - Emission order is always: `base ++ variant (definition order) ++ compound ++ caller`. Never sort or deduplicate. - Pass variant values as strings matching the map keys. Pass `null` to clear a default. - The caller `className` argument appends last; it can override variant output at the same granularity. -- Import `WindRecipe` via `package:magic/magic.dart` inside `magic_starter` files (it re-exports the wind barrel). Direct `package:fluttersdk_wind/...` imports trip `depend_on_referenced_packages`. +- Import `WindRecipe` via `package:magic/magic.dart` inside this app's files (it re-exports the wind barrel). Direct `package:fluttersdk_wind/...` imports trip `depend_on_referenced_packages`. ## Token-Only Rule diff --git a/.claude/rules/flutter-app.md b/.claude/rules/flutter-app.md new file mode 100644 index 0000000..3a361fe --- /dev/null +++ b/.claude/rules/flutter-app.md @@ -0,0 +1,35 @@ +--- +paths: + - "lib/**" + - "test/**" +--- + +# The Flutter app + +Applies to `lib/` and `test/`. Colours, the component folder contract and the anti-pattern table live in `.claude/rules/design.md`, which loads alongside this file. + +## The two skills are the standard, and this file is only where we differ + +`magic-framework` and `wind-ui` define how code on this stack is written, and copies of both sit at `.github/skills/` so a reviewer with only this checkout has them too. Load them before the first line of Dart rather than working from memory. This file does not restate them; it carries what this app does differently, and what has not been built yet. + +## No controller and no dedicated view class here yet + +This boilerplate ships zero files under `lib/app/controllers/` and zero `MagicStatefulView`. `lib/resources/views/dashboard_view.dart` and `welcome_view.dart` are plain `StatelessWidget`, reading config directly (`Config.get('app.name', ...)`) rather than through a `MagicController`. That is a real gap, not a style choice: a screen with a network call, a form, or any state that must reset between logins needs the framework's pattern rather than a hand-rolled `StatefulWidget`, and neither the `magic-framework` skill's shape nor a local instance of it exists in this repo to copy from. + +When you add the first one, follow the skill's definition rather than inventing a shape here: + +- A controller is a `MagicController` resolved through a canonical `static X get instance => Magic.findOrPut(X.new);`, notifying through `refreshUI()` rather than calling `notifyListeners()` directly. +- A view pairs with it as `MagicStatefulView` / `MagicStatefulViewState`. Do not pass a controller through a view's constructor; nothing then resets it between logins or tests. +- No app shell under `lib/ui/layouts/`. `lib/routes/app.dart:16` already mounts `magic_starter`'s `layout.app` through `MagicRoute.group(layout: ...)`; a second shell competes with it and decays. + +## Routes register in `boot()`, not `register()` + +`RouteServiceProvider.boot()` (`lib/app/providers/route_service_provider.dart`) calls `registerAppRoutes()` and the starter route registrars, then registers the dev-only preview catalog, all inside `boot()`. That is deliberate here: the preview registration must land before `MagicRouter` locks its route table on first build, and `boot()` is the phase both dev tooling and the app routes share. Do not move route registration into `register()` on the assumption that is the framework default; it is not what this repo does, and the comment at that call site explains why. + +## Config-plus-factory is the wiring shape for a new subsystem + +A subsystem gets its own `lib/config/.dart` exposing a single `Map get Config => {...}` getter, then a `() => Config` entry added to the `configFactories` list in `lib/main.dart`. `lib/config/localization.dart` and `lib/config/notifications.dart` are the two current instances (wired at `lib/main.dart:39-41`); read either before adding a third. Every value goes through `env()` with an explicit fallback rather than requiring a `.env` entry, and each non-obvious default carries a comment saying why that default and not another (see `notifications.dart`'s push section). This is the only place a subsystem is configured; do not scatter its options across the provider that consumes it. + +## Generated files, never hand-edited + +`lib/config/wind_theme.g.dart` (`design:sync`), `lib/_previews.g.dart` (`previews:refresh`), `lib/app/_plugins.g.dart` and `lib/app/commands/_index.g.dart` (`commands:refresh`). Regenerate through the dispatcher command named in parentheses; a hand edit is overwritten on the next run and diverges from `analysis_options.yaml`'s strict-mode expectations in the meantime. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c29c6b5..f7ea041 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,3 +117,37 @@ jobs: - uses: actions/checkout@v7 - name: Scan for raw color literals run: bin/design-tokens + + # No sibling checkouts and no pubspec_overrides.yaml (gitignored, never checked + # out here): this is the exact graph `flutter pub add` gives a fork. Before Step + # 1, pubspec.yaml pinned `magic_notifications: ^0.0.2`, which does not intersect + # `magic_starter` alpha.26's own `^0.2.0` requirement, so pub silently walked the + # starter back to alpha.24 and `flutter pub get` still exited 0. A resolve-only + # job would have stayed green through that regression; the version assertion + # below is what actually observes it. + published: + name: Published graph (hosted resolution) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - name: Resolve against pub.dev + run: flutter pub get + - name: Analyze against the published siblings + run: flutter analyze + # `flutter pub get` exits 0 on a downgrade as long as SOME version in the + # constraint range resolves; it does not care which one. Reading the lock's + # own hosted version is the only way to tell alpha.26 (intended) apart from + # alpha.24 (the silent walk-back this job exists to catch). + - name: Assert magic_starter resolved to the intended release + run: | + resolved=$(awk '/^ magic_starter:/{f=1; next} f && /^ version:/{print $2; exit} f && /^ [a-zA-Z]/{exit}' pubspec.lock | tr -d '"') + echo "resolved magic_starter version: ${resolved:-}" + if [ "$resolved" != "0.0.1-alpha.26" ]; then + echo "expected magic_starter 0.0.1-alpha.26, got '${resolved:-}'. A hosted resolve that lands on an older prerelease (alpha.24, measured before Step 1) means a dependency constraint stopped intersecting alpha.26's own requirements while pub get kept exiting 0." >&2 + exit 1 + fi diff --git a/AGENTS.md b/AGENTS.md index e40b809..5b06b49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ That override file is also why a green local run can be a red CI: with it, this ## Stack -- Flutter >=3.27.0, Dart >=3.6.0. +- Dart `sdk: ^3.12.2` (`pubspec.yaml:22`), with no separate Flutter version floor declared. - `magic` (framework: IoC container, ORM, auth, routing over `go_router`), `magic_starter` (auth, profile, teams, notifications, 13 opt-in features), `fluttersdk_wind` (utility-first styling through `className`), `magic_devtools` (dev-only preview catalog and dusk integration). - A Laravel backend under `backend/` as the API counterpart. @@ -48,7 +48,7 @@ A green suite is the floor, not the finish line. Anything a person clicks gets d ## Off-limits -- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/preview/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. +- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. - `backend/vendor/`, `build/`, `.dart_tool/`. - The fluttersdk packages are separate repositories. Reading them is expected; changing one is a PR in that repo under its own rules. `design:sync`, `design:lint`, `make:component`, and `previews:refresh` are `magic`'s commands, not this project's, and there is no `magic_example:artisan`. @@ -62,6 +62,10 @@ App components live in `lib/ui/components//` as a four-file atomic folder, Regeneration commands, all through the dispatcher: `dart run bin/dispatcher.dart design:sync`, `design:lint`, `previews:refresh`, `make:component [--variants=intent,size] [--slots]`. +## Mirroring the boilerplate + +This repo is the fork source for real products in the ecosystem. `uptizm/AGENTS.md` carries the sending half of this policy and names `../magic_example` as the boilerplate it was forked from. Not every fork carries it yet (depools does not), so a fork that has sent nothing back is a gap to close rather than an exemption. A structural change proven in one of those products (a rule, a skill, the component contract, tooling like `bin/check`) comes back here as its own PR, in the same piece of work that proved it out. Product code (a fork's domain models, screens, billing wiring) does not travel; only the pattern does. When a PR against this repo cites a fork as the reason for a change, that is the mechanism working as intended, not scope creep to push back on. + ## Where the instructions live This file is canonical. Everything else either points at it or is generated from it: diff --git a/bin/design-tokens b/bin/design-tokens index 03df81a..301a33d 100755 --- a/bin/design-tokens +++ b/bin/design-tokens @@ -30,8 +30,8 @@ # equally raw-color routes and are NOT scanned. Zero occurrences in # `lib/` today; add a rule here the day one appears. # - A triple-quoted Dart string is not tracked by the comment scanner, so a -# `//` or `/*` inside one is read as a comment. See `strip_comments`: the -# self-check there turns that from a silent truncation into a loud fallback. +# `//` or `/*` inside one would be read as a comment. Rather than parse them, +# a file containing `'''` or `"""` is scanned RAW: see `strip_comments`. # - A hardcoded pixel value, a missing `dark:` pair, or reaching for a new # component instead of an existing one: none of that is a regex-checkable # shape. This job is a floor, not the whole of "design-first". @@ -93,15 +93,25 @@ done # - A `/*` inside a string literal (a URL, a regex, a glob) does the same thing. # # Strings are tracked for `'` and `"` with backslash escapes. Dart's triple-quoted -# strings are NOT tracked, and that gap is covered by the invariant below rather -# than by more parsing. +# strings are NOT tracked, and there are two guards for that rather than one, +# because a single self-check does not cover both ways this can go wrong. # -# THE SELF-CHECK: `flutter analyze` guarantees every file here compiles, so an -# unterminated `/*` at end of file is impossible in real Dart. If block mode is -# still open when the file ends, this function mis-parsed, and it says so on stderr -# instead of returning a confidently truncated file. The caller then falls back to -# scanning the raw source, which can only over-report. A checker that goes quiet is -# worse than one that goes loud. +# THE SELF-CHECK catches the OPEN case. `flutter analyze` guarantees every file here +# compiles, so an unterminated `/*` at end of file is impossible in real Dart. If +# block mode is still open when the file ends, this function mis-parsed, and it says +# so on stderr and exits 3 instead of returning a confidently truncated file. +# +# It does NOT catch the CLOSED case: a `/*` inside a `'''...'''` body that some +# later ordinary `*/` closes leaves `in_block` at 0 by end of file, so nothing +# fires and the whole intervening region is dropped in silence. Same for a `//` +# inside a triple-quoted body, which truncates that line. So the caller ALSO +# refuses to trust a stripped file that contains `'''` or `"""` at all, and +# rescans it raw. One `grep -q` buys the guarantee that parsing triple quotes +# properly would. +# +# Both guards fail in the same direction on purpose. Over-reporting a comment as a +# violation costs someone a minute; under-reporting costs the gate its whole +# purpose. A checker that goes quiet is worse than one that goes loud. strip_comments() { awk -v path="$1" ' { @@ -206,10 +216,16 @@ while IFS= read -r -d '' file; do is_allowlisted "$rel" && continue stripped="$(strip_comments "$rel" < "$file")" - # Exit 3 is the stripper saying it lost track (see its header). Scan the RAW - # file instead: over-reporting a comment as a violation costs someone a minute, - # and under-reporting costs the gate its whole purpose. - if [ "$?" -eq 3 ]; then + stripped_status="$?" + + # Two reasons to distrust the strip and rescan raw, both failing toward + # over-reporting (see `strip_comments`): exit 3 is the stripper saying it lost + # track of an unterminated block, and a triple-quoted string is a construct it + # does not parse at all. `stripped_status` is captured on the line immediately + # after the assignment on purpose; read four lines down, any command inserted + # between them would silently disable the fallback and restore the exact quiet + # truncation this exists to prevent. + if [ "$stripped_status" -eq 3 ] || grep -q "'''\|\"\"\"" "$file"; then stripped="$(cat "$file")" fi diff --git a/docs/verification-loop.md b/docs/verification-loop.md index a785bc5..a67fbc8 100644 --- a/docs/verification-loop.md +++ b/docs/verification-loop.md @@ -128,9 +128,53 @@ keeps laying out at the old width, and everything renders doubled and clipped. whole page, so an overlap check reads true on every page including unchanged ones. Look at the screenshot. +## 4. Reading a system that is already running + +The three layers above run locally against code you just wrote, and they fail +loudly. Answering a question about a system that is ALREADY running, whether in +production or in a live local stack, fails quietly instead: nothing goes red, you +get a number, and the number is wrong. Rule the harness out before filing a defect. + +- **Read the identifier, never guess it.** A field or translation key that looks + right by naming convention is not the same as one you confirmed exists. A missing + column reads back as null and a missing translation key echoes itself, and + neither is distinguishable from genuinely empty data. Confirm the identifier + against the schema or the source file, not against what the name implies. +- **Take a before/after boundary from the artifact, not from your estimate of when + you acted.** A count bounded by "the N minutes since I made the change" can + include events that happened before the fix actually landed; a file's own + modification time is the real boundary. +- **The machine's clock and the app's clock can disagree.** A server running on + local time beside an app running on UTC (or the reverse) makes a timestamp + comparison read as hours of downtime when it is minutes of clock skew. Print + both clocks in the same command before drawing a conclusion from a timestamp. +- **A count command's exit status can look like failure when the count is a + correct zero.** `grep -c` exits non-zero on a zero count, and a fallback + triggered by that exit code masks a genuine, correct measurement. +- **A value assembled once and cached does not pick up a later change to the + thing it was built from.** A rendered string or composed view built before a + locale switch, a config change, or a data update stays stale until it is + rebuilt; rebuild the artifact fresh before concluding a change did not take + effect. +- **Reading a value at the wrong point in a request pipeline attributes to the + app what a layer in front of it did.** A header or scheme set by a reverse + proxy, read by hitting the app server directly instead of through the proxy, + reads as unset or wrong. +- **A 404 is not a regression until the route is confirmed to exist right now**, + not from memory of when it was added. +- **A single timeout is not evidence of a wall.** When a system enforces a + shared budget across retries, a later call inheriting a smaller remaining + budget is not evidence of new instability. Repeat the measurement before + concluding anything from one slow or failed call. + ## What counts as evidence A claim needs the artifact behind it: the `bin/check` summary, the screenshot pair, the snapshot or the response body. "Should work" and "green locally" are not evidence, and neither is a passing test that could not have failed. Screenshots and snapshots go under `.ac/evidence/`. + +A claim about a running system needs one thing more: the reading has to survive +section 4. A count is only evidence once its boundary comes from the artifact, an +identifier only once it was read rather than guessed, and a single timeout is +never evidence of a wall. From 75117989c8a42f06618075bef08ad643a53d0133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 7 Sep 2026 01:19:22 +0300 Subject: [PATCH 04/10] Track the lock a fork should build from, and gate what gets committed The lock was gitignored because one generated with pubspec_overrides.yaml active encodes sibling paths that exist on one machine. That diagnosis was right and the conclusion was not: the fix for a lock generated the wrong way is to generate it the right way. Untracked, a fork's first build resolves whatever the caret ranges happen to solve to on the day it clones, which is not what CI proved green. run_lockfile is what makes tracking safe. It reads the STAGED lock, not the working tree, and fails on a source: path entry. That split is deliberate and permanent: a local pub get puts sibling paths back in the lock every time, so the working copy stays dirty and unstaged while the index holds the hosted resolution. require_local_siblings reads the opposite state (the overrides file must be PRESENT), and the two cannot wedge because regenerating hosted-only moves that file aside and back rather than removing it. Verified by staging the real local-path lock, 11 path entries, and confirming the job fails with every other job green. An earlier probe that appended a malformed line proved nothing: pub refused to parse it and the run aborted before the gate executed. --- .github/copilot-instructions.md | 8 +- .github/instructions/design.instructions.md | 4 +- .../instructions/flutter-app.instructions.md | 35 + .gitignore | 18 +- bin/check | 33 + docs/verification-loop.md | 6 +- pubspec.lock | 1335 +++++++++++++++++ 7 files changed, 1425 insertions(+), 14 deletions(-) create mode 100644 .github/instructions/flutter-app.instructions.md create mode 100644 pubspec.lock diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e5f6bdd..08a8e0a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -17,7 +17,7 @@ That override file is also why a green local run can be a red CI: with it, this ## Stack -- Flutter >=3.27.0, Dart >=3.6.0. +- Dart `sdk: ^3.12.2` (`pubspec.yaml:22`), with no separate Flutter version floor declared. - `magic` (framework: IoC container, ORM, auth, routing over `go_router`), `magic_starter` (auth, profile, teams, notifications, 13 opt-in features), `fluttersdk_wind` (utility-first styling through `className`), `magic_devtools` (dev-only preview catalog and dusk integration). - A Laravel backend under `backend/` as the API counterpart. @@ -50,7 +50,7 @@ A green suite is the floor, not the finish line. Anything a person clicks gets d ## Off-limits -- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/preview/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. +- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. - `backend/vendor/`, `build/`, `.dart_tool/`. - The fluttersdk packages are separate repositories. Reading them is expected; changing one is a PR in that repo under its own rules. `design:sync`, `design:lint`, `make:component`, and `previews:refresh` are `magic`'s commands, not this project's, and there is no `magic_example:artisan`. @@ -64,6 +64,10 @@ App components live in `lib/ui/components//` as a four-file atomic folder, Regeneration commands, all through the dispatcher: `dart run bin/dispatcher.dart design:sync`, `design:lint`, `previews:refresh`, `make:component [--variants=intent,size] [--slots]`. +## Mirroring the boilerplate + +This repo is the fork source for real products in the ecosystem. `uptizm/AGENTS.md` carries the sending half of this policy and names `../magic_example` as the boilerplate it was forked from. Not every fork carries it yet (depools does not), so a fork that has sent nothing back is a gap to close rather than an exemption. A structural change proven in one of those products (a rule, a skill, the component contract, tooling like `bin/check`) comes back here as its own PR, in the same piece of work that proved it out. Product code (a fork's domain models, screens, billing wiring) does not travel; only the pattern does. When a PR against this repo cites a fork as the reason for a change, that is the mechanism working as intended, not scope creep to push back on. + ## Where the instructions live This file is canonical. Everything else either points at it or is generated from it: diff --git a/.github/instructions/design.instructions.md b/.github/instructions/design.instructions.md index 0fcd62f..e8ffc5b 100644 --- a/.github/instructions/design.instructions.md +++ b/.github/instructions/design.instructions.md @@ -10,7 +10,7 @@ These rules apply whenever you touch any file under `lib/`. They complement `.gi ## Atomic Component Folder Contract -Every component in the `magic_starter` generic library lives in a 4-file atomic folder: +Every component in this app's own `lib/ui/components/` library lives in a 4-file atomic folder: ``` lib/ui/components// @@ -53,7 +53,7 @@ final myRecipe = WindRecipe( - Emission order is always: `base ++ variant (definition order) ++ compound ++ caller`. Never sort or deduplicate. - Pass variant values as strings matching the map keys. Pass `null` to clear a default. - The caller `className` argument appends last; it can override variant output at the same granularity. -- Import `WindRecipe` via `package:magic/magic.dart` inside `magic_starter` files (it re-exports the wind barrel). Direct `package:fluttersdk_wind/...` imports trip `depend_on_referenced_packages`. +- Import `WindRecipe` via `package:magic/magic.dart` inside this app's files (it re-exports the wind barrel). Direct `package:fluttersdk_wind/...` imports trip `depend_on_referenced_packages`. ## Token-Only Rule diff --git a/.github/instructions/flutter-app.instructions.md b/.github/instructions/flutter-app.instructions.md new file mode 100644 index 0000000..36eb91c --- /dev/null +++ b/.github/instructions/flutter-app.instructions.md @@ -0,0 +1,35 @@ +--- +applyTo: "lib/**,test/**" +--- + + + +# The Flutter app + +Applies to `lib/` and `test/`. Colours, the component folder contract and the anti-pattern table live in `.github/instructions/design.instructions.md`, which loads alongside this file. + +## The two skills are the standard, and this file is only where we differ + +`magic-framework` and `wind-ui` define how code on this stack is written, and copies of both sit at `.github/skills/` so a reviewer with only this checkout has them too. Load them before the first line of Dart rather than working from memory. This file does not restate them; it carries what this app does differently, and what has not been built yet. + +## No controller and no dedicated view class here yet + +This boilerplate ships zero files under `lib/app/controllers/` and zero `MagicStatefulView`. `lib/resources/views/dashboard_view.dart` and `welcome_view.dart` are plain `StatelessWidget`, reading config directly (`Config.get('app.name', ...)`) rather than through a `MagicController`. That is a real gap, not a style choice: a screen with a network call, a form, or any state that must reset between logins needs the framework's pattern rather than a hand-rolled `StatefulWidget`, and neither the `magic-framework` skill's shape nor a local instance of it exists in this repo to copy from. + +When you add the first one, follow the skill's definition rather than inventing a shape here: + +- A controller is a `MagicController` resolved through a canonical `static X get instance => Magic.findOrPut(X.new);`, notifying through `refreshUI()` rather than calling `notifyListeners()` directly. +- A view pairs with it as `MagicStatefulView` / `MagicStatefulViewState`. Do not pass a controller through a view's constructor; nothing then resets it between logins or tests. +- No app shell under `lib/ui/layouts/`. `lib/routes/app.dart:16` already mounts `magic_starter`'s `layout.app` through `MagicRoute.group(layout: ...)`; a second shell competes with it and decays. + +## Routes register in `boot()`, not `register()` + +`RouteServiceProvider.boot()` (`lib/app/providers/route_service_provider.dart`) calls `registerAppRoutes()` and the starter route registrars, then registers the dev-only preview catalog, all inside `boot()`. That is deliberate here: the preview registration must land before `MagicRouter` locks its route table on first build, and `boot()` is the phase both dev tooling and the app routes share. Do not move route registration into `register()` on the assumption that is the framework default; it is not what this repo does, and the comment at that call site explains why. + +## Config-plus-factory is the wiring shape for a new subsystem + +A subsystem gets its own `lib/config/.dart` exposing a single `Map get Config => {...}` getter, then a `() => Config` entry added to the `configFactories` list in `lib/main.dart`. `lib/config/localization.dart` and `lib/config/notifications.dart` are the two current instances (wired at `lib/main.dart:39-41`); read either before adding a third. Every value goes through `env()` with an explicit fallback rather than requiring a `.env` entry, and each non-obvious default carries a comment saying why that default and not another (see `notifications.dart`'s push section). This is the only place a subsystem is configured; do not scatter its options across the provider that consumes it. + +## Generated files, never hand-edited + +`lib/config/wind_theme.g.dart` (`design:sync`), `lib/_previews.g.dart` (`previews:refresh`), `lib/app/_plugins.g.dart` and `lib/app/commands/_index.g.dart` (`commands:refresh`). Regenerate through the dispatcher command named in parentheses; a hand edit is overwritten on the next run and diverges from `analysis_options.yaml`'s strict-mode expectations in the meantime. diff --git a/.gitignore b/.gitignore index 5a973bf..6e1b3f3 100644 --- a/.gitignore +++ b/.gitignore @@ -56,13 +56,17 @@ pubspec_overrides.yaml # Local QA report (not part of the published example) /REPORT.md -# pubspec.lock is NOT committed here, unlike a normal application. This project -# is a template that gets forked, and its lock can only ever be generated with -# pubspec_overrides.yaml active, so every committed lock encodes -# `path: "../magic"` and friends: paths that do not exist in a fork. A fork's -# first `flutter pub get` writes a correct lock against the hosted constraints -# in pubspec.yaml. -pubspec.lock +# pubspec.lock IS committed here, reversing this project's earlier policy of +# ignoring it. That argument was correct about the failure mode (a lock generated with +# pubspec_overrides.yaml active encodes `path: "../magic"` and friends, paths +# that do not exist in a fork) but drew the wrong conclusion from it: the fix +# for a lock generated the WRONG way is to generate it the right way, not to +# stop tracking it and hand every fork a first build that resolves whatever +# the caret ranges in pubspec.yaml happen to solve to on the day it is cloned +# rather than what CI actually proved green. `run_lockfile` in bin/check is +# what makes tracking safe: it fails the STAGED lock when it carries a +# `source: path` entry, so a lock generated with the overrides live never +# reaches main. # Worktrees Claude Code creates for parallel sessions and isolated subagents. # Only this subdirectory: the rest of `.claude/` is tracked, because the rules diff --git a/bin/check b/bin/check index 2ccd92c..c3a85ce 100755 --- a/bin/check +++ b/bin/check @@ -182,6 +182,38 @@ require_local_siblings() { exit 1 } +# The committed pubspec.lock has to be the HOSTED-only resolution. A lock generated +# while pubspec_overrides.yaml is active encodes the sibling working trees, which +# resolve on this machine and nowhere else, so committing one breaks CI and the +# deploy at once. A local `pub get` rewrites the lock that way constantly (the +# overrides put sibling paths back in it every time), which is why this is a gate +# rather than a note: leave the resulting dirty working copy unstaged, since only +# the STAGED content is checked here. +# +# This guard and `require_local_siblings` above read opposite states on purpose +# and do not wedge each other: that one demands pubspec_overrides.yaml be PRESENT +# in the working tree, this one demands the STAGED lock carry no path from it. +# Regenerating the lock hosted-only never removes the overrides file itself +# (only moves it aside and back), so both hold at once during ordinary local work. +run_lockfile() { + local staged + staged=$(git show :pubspec.lock 2>/dev/null) || { + echo "pubspec.lock is not tracked; commit the hosted-only lock" >&2 + return 1 + } + # `source: path` rather than a relative `path: "../"`. The overrides file holds + # ABSOLUTE paths on purpose, so a poisoned lock reads `path: "/Users/..."` with + # `relative: false` and a relative-path pattern would match it zero times. The + # source line is the categorical test and covers both forms. + if printf '%s' "$staged" | grep -q 'source: path'; then + echo "pubspec.lock encodes local sibling paths. Regenerate it hosted-only:" >&2 + echo " mv pubspec_overrides.yaml /tmp/ && flutter pub get && git add pubspec.lock" >&2 + echo " mv /tmp/pubspec_overrides.yaml ." >&2 + return 1 + fi + echo "lockfile: hosted-only, no local paths" +} + bootstrap_ignored_files require_local_siblings require_backend_vendor @@ -252,6 +284,7 @@ echo wants flutter && start "flutter-analyze" run_flutter_analyze wants flutter && start "design-tokens" run_design_tokens wants flutter && start "registry" run_registry +wants flutter && start "lockfile" run_lockfile wants flutter && start "overrides-parser" run_overrides_parser wants backend && start "backend-pint" run_backend_pint diff --git a/docs/verification-loop.md b/docs/verification-loop.md index a67fbc8..b8f3c22 100644 --- a/docs/verification-loop.md +++ b/docs/verification-loop.md @@ -67,9 +67,9 @@ idempotent and preserves any other server entry. It is committed in the fast shape because that is what the fast path is for: measured here, `./bin/fsa list` is 0.63s against 5.21s for `dart run :dispatcher list`, and a dusk walk pays that per command rather than -once. `bin/fsa` keys its build cache on `pubspec.lock`, which this repo does not -track, so its first run after a clone rebuilds the binary; that clone needs -`flutter pub get` before either route works, which is already the first step. +once. `bin/fsa` keys its build cache on `pubspec.lock`, and a fresh clone now +carries that lock, so the cache key is already correct before the first +`flutter pub get`, which is still the first step either route needs. Boot the backend, then the app: diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..1466300 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1335 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + app_links: + dependency: transitive + description: + name: app_links + sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea + url: "https://pub.dev" + source: hosted + version: "7.2.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + url: "https://pub.dev" + source: hosted + version: "4.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_mcp: + dependency: transitive + description: + name: dart_mcp + sha256: "852b51da915d679be8d051e2e3b6c68ed19fe685f1c676bb35272a2d73ceac7a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + dio: + dependency: transitive + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + encrypt: + dependency: transitive + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + faker: + dependency: transitive + description: + name: faker + sha256: "544c34e9e1d322824156d5a8d451bc1bb778263b892aded24ec7ba77b0706624" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" + url: "https://pub.dev" + source: hosted + version: "11.0.3" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_dotenv: + dependency: transitive + description: + name: flutter_dotenv + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 + url: "https://pub.dev" + source: hosted + version: "6.0.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_secure_storage: + dependency: transitive + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_timezone: + dependency: transitive + description: + name: flutter_timezone + sha256: "869677426fde92dbe170fb7d2d4929f2a8343c2f5f62f08b0bb64f908630b073" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_web_auth_2: + dependency: transitive + description: + name: flutter_web_auth_2 + sha256: a7655829251ee63aae64a748f8512f36670d8eba4657b0055cdf5ef304a9d164 + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fluttersdk_artisan: + dependency: "direct main" + description: + name: fluttersdk_artisan + sha256: "15f77c2e57663c75358d4ae6e6b72c74b53a8340478522d75449141bbcef3385" + url: "https://pub.dev" + source: hosted + version: "0.0.14" + fluttersdk_dusk: + dependency: "direct main" + description: + name: fluttersdk_dusk + sha256: cb67f106452e2e655a9f5e3345c6c04b3c39fa81f1247eaa37cf2fa0b45c878c + url: "https://pub.dev" + source: hosted + version: "0.0.13" + fluttersdk_telescope: + dependency: "direct main" + description: + name: fluttersdk_telescope + sha256: "2496ce77e12fc0d4053c3e204d9cc2ec6872ec7be7acf3de8c18b5a114e272a7" + url: "https://pub.dev" + source: hosted + version: "0.0.5" + fluttersdk_wind: + dependency: transitive + description: + name: fluttersdk_wind + sha256: "7374b032db392aa092e0e05466ec3f510b32f84d0179de66691d3d022a65d65f" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + fluttersdk_wind_diagnostics_contracts: + dependency: transitive + description: + name: fluttersdk_wind_diagnostics_contracts + sha256: "3c302f7504c9e8037072b4d2be2c266fb5488aa907eda1ce9321f1ee8556c972" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + go_router: + dependency: transitive + description: + name: go_router + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea + url: "https://pub.dev" + source: hosted + version: "17.5.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: transitive + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: c403315d87aba1f815a0a401093f97808cab6cb2bdd11ca431ff8587fd7f1c00 + url: "https://pub.dev" + source: hosted + version: "7.2.17" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "50ab85d3a732227807bb871a5df0dfaa09005f24b00b6f775f500c7b62b12d7e" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "1c0c38790306fda4ed774095620444333e56a2b6bc8fc98f3a35c9398781cf54" + url: "https://pub.dev" + source: hosted + version: "0.8.13+22" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae + url: "https://pub.dev" + source: hosted + version: "0.8.13+7" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jiffy: + dependency: transitive + description: + name: jiffy + sha256: cabced5ddea4612f5e0c498df065ee8a1bd810f7ed757db910e95d2aba80161d + url: "https://pub.dev" + source: hosted + version: "6.4.5" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7" + url: "https://pub.dev" + source: hosted + version: "2.8.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + magic: + dependency: "direct main" + description: + name: magic + sha256: "6ed2efc9e09e3803c4dd180d80050eba7981325c3496e21ddb6620b25e11b3e0" + url: "https://pub.dev" + source: hosted + version: "0.0.9" + magic_deeplink: + dependency: "direct main" + description: + name: magic_deeplink + sha256: "6aa5ea94fdc926ee06958d27a3bcc8f632d17b55bfa56fdfc77e290f6728b6a0" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + magic_devtools: + dependency: "direct main" + description: + name: magic_devtools + sha256: aa7337320708cc5864a3514299a4a6049c59c977526128f14d0fd7653b8e8519 + url: "https://pub.dev" + source: hosted + version: "0.0.4" + magic_notifications: + dependency: "direct main" + description: + name: magic_notifications + sha256: "9ec9464b88ff628881a26870a2b6a45fba901acf515be451230d0691f2b7f97c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + magic_payments: + dependency: transitive + description: + name: magic_payments + sha256: a3ec0cfd8b6e7421e629d003d4da5f9b8ece0510f96e0e000024e3f43b9ee5de + url: "https://pub.dev" + source: hosted + version: "0.0.2" + magic_social_auth: + dependency: "direct main" + description: + name: magic_social_auth + sha256: "37ade352950ce84923a20a6549fd861a0928d3f8efc00c818ba38eef74b3772e" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + magic_starter: + dependency: "direct main" + description: + name: magic_starter + sha256: "86915e3dd2bec379e0ab69c829c14ebdd185b68f6bb9a714bf1d06b9d2acdef0" + url: "https://pub.dev" + source: hosted + version: "0.0.1-alpha.26" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + more: + dependency: transitive + description: + name: more + sha256: e252628d2183cc09539b686abfbd9d8302675959b89a2a8146f5f4baca6ac5ba + url: "https://pub.dev" + source: hosted + version: "4.7.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "9d233b6f2d9c52e1a2b5fbe70451d2c10ac674d3bb419d0ec8de14989d437c26" + url: "https://pub.dev" + source: hosted + version: "0.19.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 + url: "https://pub.dev" + source: hosted + version: "9.6.0" + onesignal_flutter: + dependency: transitive + description: + name: onesignal_flutter + sha256: "712e241ee08c6ef41ea8743e4983179b28c46f5f4010c047b360b3d3aa687e8b" + url: "https://pub.dev" + source: hosted + version: "5.6.8" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + purchases_flutter: + dependency: transitive + description: + name: purchases_flutter + sha256: a96cb101e072f8b1f9ba744ac455903e60adcfdf0680cbac57651f0949cda23a + url: "https://pub.dev" + source: hosted + version: "10.11.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + share_plus: + dependency: transitive + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" + url: "https://pub.dev" + source: hosted + version: "3.5.2" + sqlite3_flutter_libs: + dependency: transitive + description: + name: sqlite3_flutter_libs + sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" + url: "https://pub.dev" + source: hosted + version: "0.6.0+eol" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + timezone: + dependency: transitive + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" + url: "https://pub.dev" + source: hosted + version: "0.0.4" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" From 9860f0d387e0a5f9d9b4964d14510a5d7b93472e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 7 Sep 2026 01:42:37 +0300 Subject: [PATCH 05/10] Reduce the fork checklist to one command, and demonstrate the patterns app:rename rewrites 24 sites plus the Kotlin package directory: the Dart package name and its import sites, the Android namespace and applicationId, the iOS and macOS bundle identifiers, the three desktop build systems, the web manifest and title, .env, lib/main.dart, DESIGN.md and dependabot.yml. It derives the CURRENT identity from the tree rather than hardcoding it, which is what makes a second run a no-op. --dry-run is proven inert by hashing every file in scope before and after, not by reading git status, which cannot tell a sibling's staged file from a write. Rewrite rules scan for their anchor instead of visiting recorded line numbers. That is why the command handles the sixth PRODUCT_BUNDLE_IDENTIFIER line in the iOS project file, which the research inventory had missed. Two things the repo documented and never demonstrated now have exactly one instance each: a controller implementing SessionScopedController, so the SessionScopeSync.attach() call that has always run finally has something to reset, and a status token family in the supplement shape DESIGN.md describes. The tracked macOS plugin registrant switches to the hosted variant. A fork clones it, runs pub get against the published graph, and gets no diff, so app:rename does not refuse on its first run. The local variant is what made it refuse. The tracked lock already follows this rule for the same reason: what is committed is the graph a fork resolves, not the one this machine does. --- .claude/rules/flutter-app.md | 7 +- .../instructions/flutter-app.instructions.md | 7 +- README.md | 103 ++- lib/app/commands/_index.g.dart | 7 +- lib/app/commands/app_rename_command.dart | 791 ++++++++++++++++++ lib/app/controllers/dashboard_controller.dart | 78 ++ lib/config/example_status_tokens.dart | 34 + lib/config/wind_theme.dart | 7 +- lib/resources/views/dashboard_view.dart | 229 +++-- macos/Flutter/GeneratedPluginRegistrant.swift | 2 +- .../app/commands/app_rename_command_test.dart | 533 ++++++++++++ 11 files changed, 1666 insertions(+), 132 deletions(-) create mode 100644 lib/app/commands/app_rename_command.dart create mode 100644 lib/app/controllers/dashboard_controller.dart create mode 100644 lib/config/example_status_tokens.dart create mode 100644 test/app/commands/app_rename_command_test.dart diff --git a/.claude/rules/flutter-app.md b/.claude/rules/flutter-app.md index 3a361fe..4938703 100644 --- a/.claude/rules/flutter-app.md +++ b/.claude/rules/flutter-app.md @@ -12,14 +12,15 @@ Applies to `lib/` and `test/`. Colours, the component folder contract and the an `magic-framework` and `wind-ui` define how code on this stack is written, and copies of both sit at `.github/skills/` so a reviewer with only this checkout has them too. Load them before the first line of Dart rather than working from memory. This file does not restate them; it carries what this app does differently, and what has not been built yet. -## No controller and no dedicated view class here yet +## One controller exists, and it is the one to copy -This boilerplate ships zero files under `lib/app/controllers/` and zero `MagicStatefulView`. `lib/resources/views/dashboard_view.dart` and `welcome_view.dart` are plain `StatelessWidget`, reading config directly (`Config.get('app.name', ...)`) rather than through a `MagicController`. That is a real gap, not a style choice: a screen with a network call, a form, or any state that must reset between logins needs the framework's pattern rather than a hand-rolled `StatefulWidget`, and neither the `magic-framework` skill's shape nor a local instance of it exists in this repo to copy from. +`lib/app/controllers/dashboard_controller.dart` paired with `lib/resources/views/dashboard_view.dart` is the single worked instance of the framework's controller and view pattern in this repo. Read it before adding a second; its docblock carries the reasoning, not just the shape. `lib/resources/views/welcome_view.dart` is still a plain `StatelessWidget` reading `Config.get('app.name', ...)` directly, which is fine for a screen with no state and no identity in it. -When you add the first one, follow the skill's definition rather than inventing a shape here: +Follow the skill's definition rather than inventing a shape here: - A controller is a `MagicController` resolved through a canonical `static X get instance => Magic.findOrPut(X.new);`, notifying through `refreshUI()` rather than calling `notifyListeners()` directly. - A view pairs with it as `MagicStatefulView` / `MagicStatefulViewState`. Do not pass a controller through a view's constructor; nothing then resets it between logins or tests. +- A controller holding anything that belongs to the current identity implements `SessionScopedController`. `SessionScopeSync.attach()` (`lib/app/providers/app_service_provider.dart:81`) resets every registered one on login and team switch; `onInit` alone cannot cover this, because it runs once per controller lifetime rather than once per session. Skip it and a team switch leaves the previous tenant's data on screen until the app restarts. - No app shell under `lib/ui/layouts/`. `lib/routes/app.dart:16` already mounts `magic_starter`'s `layout.app` through `MagicRoute.group(layout: ...)`; a second shell competes with it and decays. ## Routes register in `boot()`, not `register()` diff --git a/.github/instructions/flutter-app.instructions.md b/.github/instructions/flutter-app.instructions.md index 36eb91c..8acbe87 100644 --- a/.github/instructions/flutter-app.instructions.md +++ b/.github/instructions/flutter-app.instructions.md @@ -12,14 +12,15 @@ Applies to `lib/` and `test/`. Colours, the component folder contract and the an `magic-framework` and `wind-ui` define how code on this stack is written, and copies of both sit at `.github/skills/` so a reviewer with only this checkout has them too. Load them before the first line of Dart rather than working from memory. This file does not restate them; it carries what this app does differently, and what has not been built yet. -## No controller and no dedicated view class here yet +## One controller exists, and it is the one to copy -This boilerplate ships zero files under `lib/app/controllers/` and zero `MagicStatefulView`. `lib/resources/views/dashboard_view.dart` and `welcome_view.dart` are plain `StatelessWidget`, reading config directly (`Config.get('app.name', ...)`) rather than through a `MagicController`. That is a real gap, not a style choice: a screen with a network call, a form, or any state that must reset between logins needs the framework's pattern rather than a hand-rolled `StatefulWidget`, and neither the `magic-framework` skill's shape nor a local instance of it exists in this repo to copy from. +`lib/app/controllers/dashboard_controller.dart` paired with `lib/resources/views/dashboard_view.dart` is the single worked instance of the framework's controller and view pattern in this repo. Read it before adding a second; its docblock carries the reasoning, not just the shape. `lib/resources/views/welcome_view.dart` is still a plain `StatelessWidget` reading `Config.get('app.name', ...)` directly, which is fine for a screen with no state and no identity in it. -When you add the first one, follow the skill's definition rather than inventing a shape here: +Follow the skill's definition rather than inventing a shape here: - A controller is a `MagicController` resolved through a canonical `static X get instance => Magic.findOrPut(X.new);`, notifying through `refreshUI()` rather than calling `notifyListeners()` directly. - A view pairs with it as `MagicStatefulView` / `MagicStatefulViewState`. Do not pass a controller through a view's constructor; nothing then resets it between logins or tests. +- A controller holding anything that belongs to the current identity implements `SessionScopedController`. `SessionScopeSync.attach()` (`lib/app/providers/app_service_provider.dart:81`) resets every registered one on login and team switch; `onInit` alone cannot cover this, because it runs once per controller lifetime rather than once per session. Skip it and a team switch leaves the previous tenant's data on screen until the app restarts. - No app shell under `lib/ui/layouts/`. `lib/routes/app.dart:16` already mounts `magic_starter`'s `layout.app` through `MagicRoute.group(layout: ...)`; a second shell competes with it and decays. ## Routes register in `boot()`, not `register()` diff --git a/README.md b/README.md index 8af42ec..7247210 100644 --- a/README.md +++ b/README.md @@ -17,42 +17,73 @@ never ships and is not part of the fork. ## Forking this app -Follow these steps in order; each one depends on the previous. - -1. **Rename the package.** Update `name:` in `pubspec.yaml`, then rename every - Dart `import 'package:magic_example/...'` under `lib/` and `test/` to the - new package name. Update the platform bundle identifiers too: - `android/app/build.gradle.kts` (`namespace` and `applicationId`, currently - `com.fluttersdk.magic_example`) and the iOS - `PRODUCT_BUNDLE_IDENTIFIER` entries in - `ios/Runner.xcodeproj/project.pbxproj` (currently `com.fluttersdk.magicExample`). -2. **Edit `.env`.** Set `APP_NAME` and point `API_URL` at the new backend, then - run `magic key:generate` if the app uses the `Crypt` facade. `.env` is - COMMITTED here and bundled as a Flutter asset in `pubspec.yaml`, which is - deliberate on both counts: `flutter_dotenv` can only load it on web when it is - a bundled asset, and a bundled asset that does not exist fails - `flutter build`, so gitignoring it would make every fresh clone of this - template unbuildable. It holds public client values only. A Flutter bundle - ships to every user's device and can be read out of it, so real secrets - belong on the backend, never here. `.env.example` stays as the key list. -3. **Set bundle ids and app icons.** Reuse the identifiers you set in step 1 - for the platform bundle/app ids, then replace the launcher icons under - `android/app/src/main/res/mipmap-*` and `ios/Runner/Assets.xcassets/AppIcon.appiconset/`. -4. **Edit `DESIGN.md`**, then regenerate the theme: - `dart run bin/dispatcher.dart design:sync`. This rewrites - `lib/config/wind_theme.g.dart`; never hand-edit that file. -5. **Delete `pubspec_overrides.yaml`.** It exists only to wire this app to - sibling packages under active development inside the `fluttersdk` - workspace; a fork living outside that workspace has no sibling checkouts - to point at and must resolve every `magic` package from pub.dev via the - plain `pubspec.yaml` constraints. Deleting it (it is gitignored, so it was - never committed) is what makes `flutter pub get` resolve purely hosted. - `bin/check` guards that file for the workspace case, so once it is gone run - the gate as `CHECK_ALLOW_HOSTED=1 bin/check`, which is the supported way to - say "hosted is what I meant". -6. **Refresh the preview catalog:** `dart run bin/dispatcher.dart previews:refresh`. - -After these six steps, `flutter pub get` should resolve against pub.dev alone, +Run the rename command first, it rewrites every platform identity site the +app carries. Then finish the handful of things it deliberately leaves for you. + +### 1. Rename the app + + dart run bin/dispatcher.dart app:rename --name= --org= --display="" + +Add `--dry-run` first to list every file it would touch without writing +anything. It refuses to run while writing against a dirty worktree, and +running it twice with the same arguments is a no-op. + +It owns every identity site measured in this app: the Dart package name and +its five `import 'package:magic_example/...'` sites; the Android namespace, +`applicationId`, display name, and the Kotlin package directory move +(`android/app/src/main/kotlin/com/fluttersdk/magic_example/` moves to the new +package path); the iOS bundle identifier and display name in `project.pbxproj` +and `Info.plist`; the macOS bundle identifier and display name across +`AppInfo.xcconfig`, `project.pbxproj`, and `Runner.xcscheme` (macOS's own +`Info.plist` needs no edit, it reads `$(PRODUCT_BUNDLE_IDENTIFIER)` and +`$(PRODUCT_NAME)`); the Windows `CMakeLists.txt`, `Runner.rc`, and `main.cpp`; +the Linux `CMakeLists.txt` and `my_application.cc`; the web `manifest.json` +and `index.html` title; the `APP_NAME` key in `.env` (not its values, see step +2); `lib/main.dart`; the app name reference in `DESIGN.md` (not its tokens, +see step 4); and `.github/dependabot.yml`. + +### 2. Edit `.env` values + +`app:rename` only rewrites the `APP_NAME` key; it does not touch `API_URL` or +any other value. Point `API_URL` at the new backend, then run +`magic key:generate` if the app uses the `Crypt` facade. `.env` is COMMITTED +here and bundled as a Flutter asset in `pubspec.yaml`, which is deliberate on +both counts: `flutter_dotenv` can only load it on web when it is a bundled +asset, and a bundled asset that does not exist fails `flutter build`, so +gitignoring it would make every fresh clone of this template unbuildable. It +holds public client values only. A Flutter bundle ships to every user's +device and can be read out of it, so real secrets belong on the backend, +never here. `.env.example` stays as the key list. + +### 3. Replace the launcher icons + +`app:rename` does not generate icons. Replace them under +`android/app/src/main/res/mipmap-*` and +`ios/Runner/Assets.xcassets/AppIcon.appiconset/`. + +### 4. Edit `DESIGN.md`, then regenerate the theme + +`app:rename` only updates the app name reference in `DESIGN.md`; it does not +touch the design tokens. Edit the colors, typography, spacing, and radii, +then run `dart run bin/dispatcher.dart design:sync`. This rewrites +`lib/config/wind_theme.g.dart`; never hand-edit that file. + +### 5. Delete `pubspec_overrides.yaml` + +It exists only to wire this app to sibling packages under active development +inside the `fluttersdk` workspace; a fork living outside that workspace has no +sibling checkouts to point at and must resolve every `magic` package from +pub.dev via the plain `pubspec.yaml` constraints. Deleting it (it is +gitignored, so it was never committed) is what makes `flutter pub get` +resolve purely hosted. `bin/check` guards that file for the workspace case, so +once it is gone run the gate as `CHECK_ALLOW_HOSTED=1 bin/check`, which is the +supported way to say "hosted is what I meant". + +### 6. Refresh the preview catalog + +`dart run bin/dispatcher.dart previews:refresh`. + +After these steps, `flutter pub get` should resolve against pub.dev alone, `flutter analyze` and `flutter test` should stay clean, and `/preview` in a debug build should reflect the new `DESIGN.md`. diff --git a/lib/app/commands/_index.g.dart b/lib/app/commands/_index.g.dart index 8da1c7c..4f42ac9 100644 --- a/lib/app/commands/_index.g.dart +++ b/lib/app/commands/_index.g.dart @@ -4,4 +4,9 @@ import 'package:fluttersdk_artisan/artisan.dart'; -List get commands => []; +import 'app_rename_command.dart'; + +List get commands => [ + AppRenameCommand(), +]; + diff --git a/lib/app/commands/app_rename_command.dart b/lib/app/commands/app_rename_command.dart new file mode 100644 index 0000000..abe2822 --- /dev/null +++ b/lib/app/commands/app_rename_command.dart @@ -0,0 +1,791 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; + +/// `app:rename` rewrites this app's identity across every platform folder. +/// +/// A fork of this boilerplate carries three identity facets, each spread over a +/// different file format: the Dart package name (`magic_example`), the reverse +/// DNS organisation prefix (`com.fluttersdk`) and the human display name +/// (`Magic Example`). Doing that by hand means a Gradle Kotlin DSL string, an +/// Xcode build setting, a Windows resource file, two CMake `set()` calls, a +/// Linux C constant, a JSON manifest, an HTML meta tag and a Kotlin package +/// DIRECTORY, which is why the published `rename` and `package_rename` packages +/// do not cover it: neither rewrites Dart `package:` imports. +/// +/// Every rewrite is anchored on the setting name rather than on a bare search +/// and replace, so the command is idempotent: a second run with the same +/// arguments recomputes the same value and changes nothing. +class AppRenameCommand extends ArtisanCommand { + /// [root] is the project directory to operate on. It defaults to the process + /// working directory, which is the project root under + /// `dart run bin/dispatcher.dart`; tests pass a fixture directory instead. + AppRenameCommand({Directory? root}) : _root = root ?? Directory.current; + + final Directory _root; + + /// Dart package names: a lowercase identifier. Enforced BEFORE any path is + /// built, because this value becomes the last segment of the Kotlin package + /// directory and an unchecked `../..` there escapes the project. + static final RegExp _namePattern = RegExp(r'^[a-z][a-z0-9_]*$'); + + /// Reverse DNS with at least two segments, each starting with a letter. Same + /// reason as [_namePattern]: every segment becomes a directory level under + /// `android/app/src/main/kotlin/`. + static final RegExp _orgPattern = RegExp( + r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$', + ); + + /// The display name lands inside XML attributes, a JSON string, an HTML + /// title, three C string literals and a single-quoted Dart literal. The + /// rejected characters are exactly the ones that would break out of one of + /// those quoting contexts. + static final RegExp _displayPattern = RegExp(r'''^[^"'\\<>&$\r\n]+$'''); + + @override + String get signature => + 'app:rename {--name= : New Dart package name, e.g. acme_app} ' + '{--org= : New reverse DNS prefix, e.g. com.acme} ' + '{--display= : New human display name, e.g. Acme App} ' + '{--dry-run : List every change and write nothing}'; + + @override + String get description => + 'Rewrite the app identity (package name, bundle id, display name) ' + 'across every platform.'; + + @override + CommandBoot get boot => CommandBoot.none; + + @override + Future handle(ArtisanContext ctx) async { + final bool dryRun = ctx.input.option('dry-run') == true; + + // 1. Validate the requested values before anything reads or builds a path. + final String? name = _stringOption(ctx, 'name'); + final String? org = _stringOption(ctx, 'org'); + final String? display = _stringOption(ctx, 'display'); + + if (name == null && org == null && display == null) { + ctx.output.error( + 'app:rename needs at least one of --name, --org or --display.', + ); + return 1; + } + if (name != null && !_namePattern.hasMatch(name)) { + ctx.output.error( + 'app:rename refused --name="$name": it must match ' + '${_namePattern.pattern} (a lowercase Dart package identifier). ' + 'This value becomes a Kotlin package directory.', + ); + return 1; + } + if (org != null && !_orgPattern.hasMatch(org)) { + ctx.output.error( + 'app:rename refused --org="$org": it must match ${_orgPattern.pattern} ' + '(reverse DNS, at least two dot-separated lowercase segments). ' + 'Every segment becomes a Kotlin package directory level.', + ); + return 1; + } + if (display != null && !_displayPattern.hasMatch(display)) { + ctx.output.error( + 'app:rename refused --display="$display": it may not contain any of ' + r'''" ' \ < > & $ ''' + 'or a line break, because it is written into XML, JSON, HTML, C and ' + 'Dart string literals.', + ); + return 1; + } + + // 2. Read the identity the tree currently carries. Deriving it instead of + // hardcoding `magic_example` is what lets the command run a second time + // on an already-renamed fork. + final _Identity? current = _readCurrentIdentity(ctx); + if (current == null) return 1; + + final target = _Identity( + package: name ?? current.package, + org: org ?? current.org, + display: display ?? current.display, + ); + + // 3. Refuse a dirty worktree, but only when actually writing. A dry run has + // to stay usable in the middle of unrelated work. + if (!dryRun) { + final String? dirty = _dirtyWorktreeReason(); + if (dirty != null) { + ctx.output.error( + 'app:rename refused to write: $dirty\n' + 'This command rewrites files across every platform folder and moves ' + 'a Kotlin package directory; git is the only undo. Commit or stash ' + 'first, or pass --dry-run.', + ); + return 1; + } + } + + // 4. Build the whole plan before touching anything, so a dry run and a real + // run report from the same data. + final plan = _buildPlan(current, target); + + _report(ctx, current, target, plan, dryRun: dryRun); + + if (dryRun) return 0; + + _apply(ctx, plan); + return 0; + } + + // --------------------------------------------------------------------------- + // Reading the current identity + // --------------------------------------------------------------------------- + + /// Derives the identity from three anchored sites. Returns null and reports + /// on stderr when the tree does not carry one; a partial guess here would + /// silently produce a half-renamed project. + _Identity? _readCurrentIdentity(ArtisanContext ctx) { + final pubspec = _read('pubspec.yaml'); + if (pubspec == null) { + ctx.output.error( + 'app:rename found no pubspec.yaml under ${_root.path}. ' + 'Run it from the project root.', + ); + return null; + } + final packageMatch = RegExp( + r'^name:\s*(\S+)\s*$', + multiLine: true, + ).firstMatch(pubspec); + if (packageMatch == null) { + ctx.output.error('app:rename found no `name:` line in pubspec.yaml.'); + return null; + } + final package = packageMatch.group(1)!; + + final gradle = _read('android/app/build.gradle.kts'); + if (gradle == null) { + ctx.output.error( + 'app:rename found no android/app/build.gradle.kts; it is the source ' + 'for the current organisation prefix.', + ); + return null; + } + final namespaceMatch = RegExp( + r'^\s*namespace = "([^"]*)"\s*$', + multiLine: true, + ).firstMatch(gradle); + if (namespaceMatch == null) { + ctx.output.error( + 'app:rename found no `namespace = "..."` in ' + 'android/app/build.gradle.kts.', + ); + return null; + } + final namespace = namespaceMatch.group(1)!; + if (!namespace.endsWith('.$package')) { + ctx.output.error( + 'app:rename refused: the Android namespace "$namespace" does not end ' + 'in ".$package", so the organisation prefix cannot be separated from ' + 'the package name. Align android/app/build.gradle.kts with ' + 'pubspec.yaml first.', + ); + return null; + } + final org = namespace.substring(0, namespace.length - package.length - 1); + + // The display name is read from .env rather than DESIGN.md because .env is + // the value the running app reads through `Config.get('app.name')`, and + // pubspec.yaml declares it as a bundled asset so it is always present. + final env = _read('.env'); + if (env == null) { + ctx.output.error( + 'app:rename found no .env; APP_NAME there is the current display name.', + ); + return null; + } + final displayMatch = RegExp( + r'^APP_NAME=(.*)$', + multiLine: true, + ).firstMatch(env); + if (displayMatch == null) { + ctx.output.error('app:rename found no `APP_NAME=` line in .env.'); + return null; + } + + return _Identity( + package: package, + org: org, + display: displayMatch.group(1)!.trim(), + ); + } + + // --------------------------------------------------------------------------- + // The dirty-worktree guard + // --------------------------------------------------------------------------- + + /// Returns a human reason when the tree holds changes a rename would bury, + /// or null when writing is safe. + String? _dirtyWorktreeReason() { + final probe = Process.runSync('git', const [ + 'rev-parse', + '--is-inside-work-tree', + ], workingDirectory: _root.path); + // Not a git worktree at all: there is nothing for the guard to protect. + if (probe.exitCode != 0) return null; + + final status = Process.runSync('git', const [ + 'status', + '--porcelain', + ], workingDirectory: _root.path); + if (status.exitCode != 0) { + return 'git status failed (${status.stderr.toString().trim()}).'; + } + + final blocking = []; + for (final line in status.stdout.toString().split('\n')) { + if (line.trim().isEmpty) continue; + final code = line.substring(0, 2); + final path = line.substring(3).trim(); + // Untracked files are not at risk: nothing this command writes replaces + // them, and git can still restore everything it tracks. + if (code == '??') continue; + // pubspec.lock is tracked here and PERMANENTLY dirty by design: the index + // holds the hosted-only resolution while a local `flutter pub get` + // rewrites the working copy with sibling paths. Blocking on it would make + // the command unusable in this workspace. + if (path == 'pubspec.lock') continue; + blocking.add(line); + } + if (blocking.isEmpty) return null; + + return 'the worktree has ${blocking.length} uncommitted change(s):\n' + '${blocking.join('\n')}'; + } + + // --------------------------------------------------------------------------- + // Planning + // --------------------------------------------------------------------------- + + _RenamePlan _buildPlan(_Identity from, _Identity to) { + final changed = <_PlannedFile>[]; + final unchanged = []; + final absent = []; + + for (final rewrite in [ + ..._identityRewrites(from, to), + ..._dartImportRewrites(from, to), + ]) { + final source = _read(rewrite.path); + if (source == null) { + absent.add(rewrite.path); + continue; + } + var updated = source; + for (final rule in rewrite.rules) { + updated = updated.replaceAllMapped(rule.pattern, rule.replace); + } + if (updated == source) { + unchanged.add(rewrite.path); + continue; + } + changed.add( + _PlannedFile(rewrite.path, updated, _changedLines(source, updated)), + ); + } + + return _RenamePlan( + changed: changed, + unchanged: unchanged, + absent: absent, + move: _planKotlinMove(from, to), + ); + } + + /// The Kotlin package is a DIRECTORY whose path mirrors the package + /// declaration, so a rename that only rewrites the `package` line leaves + /// Gradle looking for the old identity (flutter/flutter#55318). Moving the + /// directory is the load-bearing half of the Android rename. + _PlannedMove? _planKotlinMove(_Identity from, _Identity to) { + final source = 'android/app/src/main/kotlin/${from.kotlinPath}'; + final destination = 'android/app/src/main/kotlin/${to.kotlinPath}'; + if (source == destination) return null; + if (!Directory(_absolute(source)).existsSync()) return null; + return _PlannedMove(source, destination); + } + + List<_FileRewrite> _identityRewrites(_Identity from, _Identity to) { + final oldAppleId = RegExp.escape(from.appleId); + + return <_FileRewrite>[ + _FileRewrite('pubspec.yaml', [ + _Rule( + RegExp(r'^name:\s*\S+\s*$', multiLine: true), + (_) => 'name: ${to.package}', + ), + ]), + _FileRewrite('.env', [ + _Rule( + RegExp(r'^APP_NAME=.*$', multiLine: true), + (_) => 'APP_NAME=${to.display}', + ), + ]), + _FileRewrite('DESIGN.md', [ + // The prose body names the app; there is no setting to anchor on, so + // the old display name is the anchor. It runs BEFORE the frontmatter + // rule: when the new name contains the old one ("Magic" -> "Magic + // Example"), running it second would rewrite the already-correct + // frontmatter into "Magic Example Example". + _Rule(_wordBounded(from.display), (_) => to.display), + _Rule( + RegExp(r'^name: .*$', multiLine: true), + (_) => 'name: ${to.display}', + ), + ]), + _FileRewrite('.github/dependabot.yml', [ + _Rule( + RegExp(r'^(# Dependabot config for [^/\s]+/)\S+$', multiLine: true), + (m) => '${m[1]}${to.package}', + ), + ]), + _FileRewrite('lib/main.dart', [ + _Rule( + RegExp(r"(MagicApplication\(title: ')[^']*(')"), + (m) => '${m[1]}${to.display}${m[2]}', + ), + ]), + + // Android. + _FileRewrite('android/app/build.gradle.kts', [ + _Rule( + RegExp(r'^(\s*)namespace = "[^"]*"$', multiLine: true), + (m) => '${m[1]}namespace = "${to.androidId}"', + ), + _Rule( + RegExp(r'^(\s*)applicationId = "[^"]*"$', multiLine: true), + (m) => '${m[1]}applicationId = "${to.androidId}"', + ), + ]), + _FileRewrite('android/app/src/main/AndroidManifest.xml', [ + _Rule( + RegExp(r'android:label="[^"]*"'), + (_) => 'android:label="${to.display}"', + ), + ]), + _FileRewrite( + 'android/app/src/main/kotlin/${from.kotlinPath}/MainActivity.kt', + [ + _Rule( + RegExp(r'^package .*$', multiLine: true), + (_) => 'package ${to.androidId}', + ), + ], + ), + + // iOS. macos/Runner/Info.plist deliberately has no entry: it reads + // $(PRODUCT_BUNDLE_IDENTIFIER) and $(PRODUCT_NAME) from the xcconfig. + _FileRewrite('ios/Runner/Info.plist', [ + _Rule( + RegExp( + r'(CFBundleDisplayName\s*)[^<]*()', + ), + (m) => '${m[1]}${to.display}${m[2]}', + ), + _Rule( + RegExp(r'(CFBundleName\s*)[^<]*()'), + (m) => '${m[1]}${to.package}${m[2]}', + ), + ]), + // A pbxproj is a nested property list where the same token appears in + // object ids, comments and build phases. Anchoring on the setting name + // AND the current value is what keeps a rename from mangling the file; + // the `([^;\n]*)` tail preserves suffixes such as `.RunnerTests`. + _FileRewrite('ios/Runner.xcodeproj/project.pbxproj', [ + _Rule( + RegExp('PRODUCT_BUNDLE_IDENTIFIER = $oldAppleId([^;\n]*);'), + (m) => 'PRODUCT_BUNDLE_IDENTIFIER = ${to.appleId}${m[1]};', + ), + ]), + + // macOS. + _FileRewrite('macos/Runner/Configs/AppInfo.xcconfig', [ + _Rule( + RegExp(r'^PRODUCT_NAME = .*$', multiLine: true), + (_) => 'PRODUCT_NAME = ${to.package}', + ), + _Rule( + RegExp(r'^PRODUCT_BUNDLE_IDENTIFIER = .*$', multiLine: true), + (_) => 'PRODUCT_BUNDLE_IDENTIFIER = ${to.appleId}', + ), + _Rule( + RegExp(r'^PRODUCT_COPYRIGHT = .*$', multiLine: true), + (m) => m[0]!.replaceAll(from.org, to.org), + ), + ]), + _FileRewrite('macos/Runner.xcodeproj/project.pbxproj', [ + _Rule( + RegExp('PRODUCT_BUNDLE_IDENTIFIER = $oldAppleId([^;\n]*);'), + (m) => 'PRODUCT_BUNDLE_IDENTIFIER = ${to.appleId}${m[1]};', + ), + ]), + _FileRewrite( + 'macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', + [ + _Rule( + RegExp(r'BuildableName = "[^"]*\.app"'), + (_) => 'BuildableName = "${to.package}.app"', + ), + ], + ), + + // Windows. + _FileRewrite('windows/CMakeLists.txt', [ + _Rule( + RegExp(r'^project\([^\s)]+ LANGUAGES CXX\)$', multiLine: true), + (_) => 'project(${to.package} LANGUAGES CXX)', + ), + _Rule( + RegExp(r'^set\(BINARY_NAME "[^"]*"\)$', multiLine: true), + (_) => 'set(BINARY_NAME "${to.package}")', + ), + ]), + _FileRewrite('windows/runner/main.cpp', [ + _Rule( + RegExp(r'(window\.Create\(L")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + ]), + _FileRewrite('windows/runner/Runner.rc', [ + _rcValue('CompanyName', (_) => to.org), + _rcValue('FileDescription', (_) => to.display), + _rcValue('InternalName', (_) => to.package), + _rcValue('LegalCopyright', (v) => v.replaceAll(from.org, to.org)), + _rcValue('OriginalFilename', (_) => '${to.package}.exe'), + _rcValue('ProductName', (_) => to.display), + ]), + + // Linux. + _FileRewrite('linux/CMakeLists.txt', [ + _Rule( + RegExp(r'^set\(BINARY_NAME "[^"]*"\)$', multiLine: true), + (_) => 'set(BINARY_NAME "${to.package}")', + ), + _Rule( + RegExp(r'^set\(APPLICATION_ID "[^"]*"\)$', multiLine: true), + (_) => 'set(APPLICATION_ID "${to.androidId}")', + ), + ]), + _FileRewrite('linux/runner/my_application.cc', [ + _Rule( + RegExp(r'(gtk_header_bar_set_title\(header_bar, ")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + _Rule( + RegExp(r'(gtk_window_set_title\(window, ")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + ]), + + // Web. + _FileRewrite('web/manifest.json', [ + _Rule( + RegExp(r'("name":\s*")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + _Rule( + RegExp(r'("short_name":\s*")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + ]), + _FileRewrite('web/index.html', [ + _Rule( + RegExp( + r'( '${m[1]}${to.display}${m[2]}', + ), + _Rule( + RegExp(r'[^<]*'), + (_) => '${to.display}', + ), + ]), + ]; + } + + /// Matches [text] as a whole word so a display name that is a prefix of a + /// longer word ("App" inside "Application") is left alone. The boundary is + /// dropped when the name does not start and end on a word character, because + /// `\b` next to punctuation would then never match. + RegExp _wordBounded(String text) { + final escaped = RegExp.escape(text); + final wordEdge = RegExp(r'^\w.*\w$|^\w$', dotAll: true); + return wordEdge.hasMatch(text) ? RegExp('\\b$escaped\\b') : RegExp(escaped); + } + + /// One `VALUE "", "" "\0"` entry of a Windows resource file. + _Rule _rcValue(String key, String Function(String current) value) { + return _Rule( + RegExp('(VALUE "$key", ")([^"]*)(")'), + (m) => '${m[1]}${value(m[2]!)}${m[3]}', + ); + } + + /// Dart `package:/` imports. Scanned rather than listed, because a fork + /// adds its own and a stale hardcoded list would leave the app uncompilable. + List<_FileRewrite> _dartImportRewrites(_Identity from, _Identity to) { + final needle = 'package:${from.package}/'; + final replacement = 'package:${to.package}/'; + final out = <_FileRewrite>[]; + for (final dir in const ['lib', 'test', 'bin']) { + final directory = Directory(_absolute(dir)); + if (!directory.existsSync()) continue; + for (final entity in directory.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + if (!entity.readAsStringSync().contains(needle)) continue; + out.add( + _FileRewrite(_relative(entity.path), [ + _Rule(RegExp(RegExp.escape(needle)), (_) => replacement), + ]), + ); + } + } + out.sort((a, b) => a.path.compareTo(b.path)); + return out; + } + + // --------------------------------------------------------------------------- + // Reporting and applying + // --------------------------------------------------------------------------- + + void _report( + ArtisanContext ctx, + _Identity from, + _Identity to, + _RenamePlan plan, { + required bool dryRun, + }) { + final out = ctx.output; + out.writeln( + dryRun ? 'app:rename (dry run, nothing is written)' : 'app:rename', + ); + out.writeln(' package ${from.package} -> ${to.package}'); + out.writeln(' org ${from.org} -> ${to.org}'); + out.writeln(' display ${from.display} -> ${to.display}'); + out.writeln(''); + + out.writeln('changed (${plan.changed.length}):'); + for (final file in plan.changed) { + out.writeln(' ${file.path} (${file.changedLines} line(s))'); + } + out.writeln('unchanged (${plan.unchanged.length}):'); + for (final path in plan.unchanged) { + out.writeln(' $path'); + } + if (plan.absent.isNotEmpty) { + out.writeln('absent (${plan.absent.length}):'); + for (final path in plan.absent) { + out.writeln(' $path'); + } + } + + final move = plan.move; + out.writeln('kotlin package directory:'); + out.writeln( + move == null + ? ' already at android/app/src/main/kotlin/${to.kotlinPath}' + : ' ${move.source} -> ${move.destination}', + ); + + out.writeln(''); + out.writeln('not owned by app:rename, do these by hand:'); + out.writeln(' - launcher icons on every platform'); + out.writeln( + ' - macos/Runner.xcodeproj/project.pbxproj product references and ' + 'TEST_HOST still name "${from.package}.app"; only anchored ' + 'PRODUCT_BUNDLE_IDENTIFIER lines are rewritten there', + ); + out.writeln(' - README.md and AGENTS.md prose'); + out.writeln( + ' - the remaining .env values, then ' + '`dart run bin/dispatcher.dart design:sync`', + ); + } + + void _apply(ArtisanContext ctx, _RenamePlan plan) { + // Files first: MainActivity.kt is rewritten at its old path, then the whole + // directory moves underneath it. + for (final file in plan.changed) { + File(_absolute(file.path)).writeAsStringSync(file.content); + } + + final move = plan.move; + if (move != null) { + final source = Directory(_absolute(move.source)); + final destination = Directory(_absolute(move.destination)); + destination.createSync(recursive: true); + for (final entity in source.listSync()) { + if (entity is! File) continue; + final name = _relative(entity.path).split('/').last; + entity.renameSync('${destination.path}/$name'); + } + source.deleteSync(recursive: true); + _pruneEmptyParents(source.parent, 'android/app/src/main/kotlin'); + } + + if (plan.changed.isEmpty && move == null) { + ctx.output.success( + 'Nothing to do: the tree already carries this identity.', + ); + return; + } + + ctx.output.success( + 'Renamed. ${plan.changed.length} file(s) rewritten' + '${move == null ? '' : ', 1 package directory moved'}. ' + 'Run `flutter pub get` before anything else: the Dart package name ' + 'changed, so the current .dart_tool resolution is stale.', + ); + } + + /// Removes the organisation directories the move emptied. Git does not track + /// empty directories, so leaving them behind would make a fresh clone of the + /// fork differ from the machine the rename ran on. + void _pruneEmptyParents(Directory directory, String stopAtRelative) { + final stop = _absolute(stopAtRelative); + var current = directory; + while (current.path != stop && + current.existsSync() && + current.listSync().isEmpty) { + final parent = current.parent; + current.deleteSync(); + current = parent; + } + } + + // --------------------------------------------------------------------------- + // Small helpers + // --------------------------------------------------------------------------- + + String? _stringOption(ArtisanContext ctx, String name) { + final value = ctx.input.option(name); + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + String _absolute(String relative) => '${_root.path}/$relative'; + + String _relative(String absolute) { + final prefix = '${_root.path}/'; + return absolute.startsWith(prefix) + ? absolute.substring(prefix.length) + : absolute; + } + + String? _read(String relative) { + final file = File(_absolute(relative)); + return file.existsSync() ? file.readAsStringSync() : null; + } + + /// Every rule is line-local, so the line count never shifts and an + /// index-wise comparison is exact. + int _changedLines(String before, String after) { + final a = before.split('\n'); + final b = after.split('\n'); + var count = 0; + for (var i = 0; i < a.length && i < b.length; i++) { + if (a[i] != b[i]) count++; + } + return count + (a.length - b.length).abs(); + } +} + +/// The three identity facets plus everything derived from them. +class _Identity { + const _Identity({ + required this.package, + required this.org, + required this.display, + }); + + /// Dart package name, e.g. `acme_app`. + final String package; + + /// Reverse DNS organisation prefix, e.g. `com.acme`. + final String org; + + /// Human display name, e.g. `Acme App`. + final String display; + + /// Android `namespace` / `applicationId` and the Linux GTK application id. + /// Android keeps the underscored form; the underscore is significant to + /// plugin discovery (flutter/flutter#55318). + String get androidId => '$org.$package'; + + /// Apple bundle identifier. Apple's tooling rejects underscores in a bundle + /// id, so the package name is camel cased here and only here. + String get appleId => '$org.$_camelPackage'; + + /// Directory path under `android/app/src/main/kotlin/`. + String get kotlinPath => '${org.split('.').join('/')}/$package'; + + String get _camelPackage { + final parts = package.split('_'); + return parts.first + + parts + .skip(1) + .where((p) => p.isNotEmpty) + .map((p) => p[0].toUpperCase() + p.substring(1)) + .join(); + } +} + +typedef _Replacer = String Function(Match match); + +/// One anchored rewrite inside a file. +class _Rule { + const _Rule(this.pattern, this.replace); + + final RegExp pattern; + final _Replacer replace; +} + +/// Every rule that applies to one file, keyed by its project-relative path. +class _FileRewrite { + const _FileRewrite(this.path, this.rules); + + final String path; + final List<_Rule> rules; +} + +/// A file whose rewritten content is ready to be written. +class _PlannedFile { + const _PlannedFile(this.path, this.content, this.changedLines); + + final String path; + final String content; + final int changedLines; +} + +/// The Kotlin package directory move. +class _PlannedMove { + const _PlannedMove(this.source, this.destination); + + final String source; + final String destination; +} + +/// The complete rename, computed before anything is written. +class _RenamePlan { + const _RenamePlan({ + required this.changed, + required this.unchanged, + required this.absent, + required this.move, + }); + + final List<_PlannedFile> changed; + final List unchanged; + final List absent; + final _PlannedMove? move; +} diff --git a/lib/app/controllers/dashboard_controller.dart b/lib/app/controllers/dashboard_controller.dart new file mode 100644 index 0000000..228979f --- /dev/null +++ b/lib/app/controllers/dashboard_controller.dart @@ -0,0 +1,78 @@ +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +import '../models/user.dart'; + +/// State behind the dashboard's greeting: the display name of whoever is +/// currently authenticated. +/// +/// **This is the first controller in the app, so it is the pattern the other +/// screens copy.** Its shape follows `depools`' `ProductController` and +/// `magic_starter`'s own controllers: a [MagicController] resolved once +/// through [Magic.findOrPut], and [MagicStateMixin] carrying loading/success +/// so [DashboardView] renders both from one source instead of jumping +/// straight to content. +/// +/// ### Why a controller for a screen with no mutations +/// +/// [DashboardView] greets the CURRENT identity, which makes it session-scoped +/// whether or not a backend sits behind it. `SessionScopeSync.attach()` +/// (`app_service_provider.dart:81`) resets every registered +/// [SessionScopedController] on login and team switch; before this class +/// nothing implemented the contract, so that call ran and had nothing to +/// reset. Without [resetForSession], a team switch would leave the previous +/// tenant's name on screen until the app restarted (see +/// [SessionScopedController]'s own docblock for why `onInit` alone cannot +/// catch this: it runs once per controller lifetime, not once per session). +/// +/// ### What "loading" honestly represents here +/// +/// This boilerplate ships no dashboard endpoint, so there is no network call +/// to await. The single `await` in [load] is not a `Future.delayed` standing +/// in for one: it is the same yield every other controller in this codebase +/// gets for free from its first `await Http.get(...)`, kept here so +/// [resetForSession]'s clear-then-refetch contract is real (the view actually +/// paints the cleared state before the refetch lands) rather than a same-tick +/// no-op indistinguishable from skipping the interface entirely. A fork +/// wiring a real fetch (a `/dashboard` summary, say) replaces the body of +/// [load] without touching [onInit] or [resetForSession]. +class DashboardController extends MagicController + with MagicStateMixin + implements SessionScopedController { + /// The shared instance, resolved once and reused for the app's lifetime. + static DashboardController get instance => + Magic.findOrPut(DashboardController.new); + + @override + void onInit() { + super.onInit(); + load(); + } + + /// Resolves the greeting name for the currently authenticated user. + Future load() async { + setLoading(); + + // The yield described in the class docblock above: there is no I/O to + // await yet, only the framework-standard boundary a real fetch would + // occupy. + await Future.value(); + + final String? name = User.current.name; + + setSuccess(name != null && name.trim().isNotEmpty ? name : 'there'); + } + + /// Clears the previous session's greeting and resolves the new one. + /// + /// Called on login and team switch, never on logout (see + /// [SessionScopedController.resetForSession]'s own contract: a logout only + /// routes to the login screen, which never reads this controller). A plain + /// [load] would leave the previous name on screen until the refetch + /// resolves, which is exactly the wrong default across an identity change. + @override + Future resetForSession() async { + setState(null, status: const RxStatus.empty()); + await load(); + } +} diff --git a/lib/config/example_status_tokens.dart b/lib/config/example_status_tokens.dart new file mode 100644 index 0000000..5c1027e --- /dev/null +++ b/lib/config/example_status_tokens.dart @@ -0,0 +1,34 @@ +/// Status-vocabulary tokens `design:sync` does not emit, merged into +/// `WindThemeData`'s alias map alongside `supplementAliases` in +/// `wind_theme.dart`. +/// +/// DESIGN.md's "Custom token families (supplement)" section documents this +/// exact mechanism: `design:sync` emits `bg-success` and `bg-warning` from +/// its `colors` block, but no `info` role at all and no `text-on-*` foreground +/// for either `success` or `warning` (compare `bg-destructive`, which DOES get +/// a `text-on-destructive` peer). Those gaps are hand-authored here, in the +/// same `' dark:'` className-string shape as `supplementAliases`, +/// and merged in `lib/main.dart` rather than regenerated. +const Map exampleStatusAliases = { + // bg-info: an informational tone. DESIGN.md's `colors` block defines no + // `info` role at all, so `design:sync` has nothing to emit for it. Sky + // blue, distinct from both the violet brand and the indigo accent so an + // info banner does not read as a brand action. + 'bg-info': 'bg-[#0284C7] dark:bg-[#0EA5E9]', + + // text-on-info: the foreground that sits ON the solid `bg-info` fill, the + // same role `text-on-primary`/`text-on-destructive` carry for their own + // backgrounds. White in both modes: the solid tone is dark enough at both + // hexes for white text to clear WCAG AA. + 'text-on-info': 'text-[#FFFFFF] dark:text-[#FFFFFF]', + + // text-on-success: `design:sync` emits `bg-success` but no foreground peer + // for it (unlike `bg-destructive`, which gets `text-on-destructive`). White + // clears AA against both success hexes. + 'text-on-success': 'text-[#FFFFFF] dark:text-[#FFFFFF]', + + // text-on-warning: same gap as `success` above, but with a dark foreground + // rather than white: amber is too light for white text to clear AA the way + // the darker info/success/destructive tones do. + 'text-on-warning': 'text-[#111827] dark:text-[#111827]', +}; diff --git a/lib/config/wind_theme.dart b/lib/config/wind_theme.dart index e12c8ff..fb1935f 100644 --- a/lib/config/wind_theme.dart +++ b/lib/config/wind_theme.dart @@ -1,5 +1,6 @@ import 'package:magic/magic.dart'; +import 'example_status_tokens.dart'; import 'wind_theme.g.dart'; /// The app's Wind theme, assembled in ONE place. @@ -17,7 +18,11 @@ import 'wind_theme.g.dart'; WindThemeData buildWindTheme() { return WindThemeData( colors: designColors, - aliases: {...designAliases, ...supplementAliases}, + aliases: { + ...designAliases, + ...supplementAliases, + ...exampleStatusAliases, + }, ); } diff --git a/lib/resources/views/dashboard_view.dart b/lib/resources/views/dashboard_view.dart index fed17a0..263f27e 100644 --- a/lib/resources/views/dashboard_view.dart +++ b/lib/resources/views/dashboard_view.dart @@ -10,21 +10,35 @@ import 'package:magic_starter/magic_starter.dart' MSUpgradeNudge, UpgradePrompt; +import '../../app/controllers/dashboard_controller.dart'; + /// Dashboard view: the default landing page after successful authentication. /// /// Design-first: every surface and text color flows through the semantic /// alias tokens (`bg-surface`, `text-fg`, ...) so it tracks DESIGN.md in both /// light and dark. The quick-link tiles compose the shared [MSCard] component. /// +/// Reads the greeting name through [DashboardController], the app's first +/// controller: the outer card renders the controller's loading state while +/// the identity resolves and its loaded state once the name is in, rather +/// than jumping straight to content (see the controller's own docblock for +/// what "loading" honestly means on a screen with no backend). +/// /// The "AI Insights" banner below the quick links is a DEMO of the /// [UpgradePrompt] seam, not a real billing integration: a fork with no plan /// gating can delete the whole "3. Plan-gate demo" block, and a fork that /// does gate features can copy the pattern into a real controller's non-2xx /// branch (`UpgradePrompt.showIfGated(response)`). -class DashboardView extends StatelessWidget { +class DashboardView extends MagicStatefulView { /// Creates the [DashboardView]. const DashboardView({super.key}); + @override + State createState() => _DashboardViewState(); +} + +class _DashboardViewState + extends MagicStatefulViewState { static const _iconHero = Icons.auto_awesome; static const _iconDocs = Icons.menu_book; static const _iconGitHub = Icons.code; @@ -43,112 +57,153 @@ class DashboardView extends StatelessWidget { /// them sends `?upgrade=Pro` where the catalog expects `pro`. static const _demoRequiredPlanLabel = 'Pro'; + @override + void initState() { + // Registers the controller before the base class resolves it, matching + // the canonical pairing (see DashboardController's docblock): this view + // is the controller's only backer, so nothing else would ever create it. + Magic.findOrPut(DashboardController.new); + super.initState(); + } + @override Widget build(BuildContext context) { final appName = Config.get('app.name', 'My App') ?? 'My App'; return WDiv( className: 'w-full max-w-[480px] md:max-w-4xl mx-auto p-4 lg:p-8', - child: WDiv( - className: ''' + child: controller.renderState( + (name) => _buildLoaded(appName: appName, greetingName: name), + onLoading: _buildSkeleton(), + ), + ); + } + + /// The card's shape while [DashboardController] resolves the greeting. + /// + /// Same outer proportions as the loaded card so the layout does not jump + /// once the name lands; only the hero icon and a status line render. + Widget _buildSkeleton() { + return WDiv( + className: ''' + rounded-2xl bg-surface-container + border border-color-border + p-6 lg:p-8 flex flex-col items-center + ''', + children: [ + WDiv( + className: ''' + w-20 h-20 rounded-2xl + flex items-center justify-center + bg-primary + ''', + child: const WIcon(_iconHero, className: 'text-4xl text-on-primary'), + ), + const WSpacer(className: 'h-6'), + const MSTypography( + 'Loading your dashboard...', + variant: TypographyVariant.caption, + ), + ], + ); + } + + /// The card's content once [DashboardController] has resolved a greeting. + Widget _buildLoaded({required String appName, required String greetingName}) { + return WDiv( + className: ''' rounded-2xl bg-surface-container border border-color-border p-6 lg:p-8 flex flex-col items-center ''', - children: [ - // 1. Hero. - WDiv( - className: ''' + children: [ + // 1. Hero. + WDiv( + className: ''' w-20 h-20 rounded-2xl flex items-center justify-center bg-primary ''', - child: const WIcon( - _iconHero, - className: 'text-4xl text-on-primary', - ), - ), - const WSpacer(className: 'h-6'), - MSTypography( - appName, - variant: TypographyVariant.h2, - className: 'text-center', - ), - const WSpacer(className: 'h-2'), - const MSTypography( - 'Built with Magic Starter', - variant: TypographyVariant.caption, - ), + child: const WIcon(_iconHero, className: 'text-4xl text-on-primary'), + ), + const WSpacer(className: 'h-6'), + MSTypography( + appName, + variant: TypographyVariant.h2, + className: 'text-center', + ), + const WSpacer(className: 'h-2'), + MSTypography( + 'Welcome back, $greetingName', + variant: TypographyVariant.caption, + ), - const WSpacer(className: 'h-8'), + const WSpacer(className: 'h-8'), - // 2. Quick-link cards. - WDiv( - className: 'w-full grid grid-cols-1 md:grid-cols-3 gap-3', - children: [ - _buildLinkCard( - icon: _iconDocs, - title: 'Documentation', - description: 'Read the Magic Framework docs to get started.', - url: 'https://magic.fluttersdk.com', - ), - _buildLinkCard( - icon: _iconGitHub, - title: 'GitHub', - description: - 'Star the repo, report issues, or contribute code.', - url: 'https://github.com/fluttersdk/magic', - ), - _buildLinkCard( - icon: _iconCli, - title: 'CLI Commands', - description: - 'Run `magic --help` to see all available commands.', - url: 'https://magic.fluttersdk.com/cli', - ), - ], - ), + // 2. Quick-link cards. + WDiv( + className: 'w-full grid grid-cols-1 md:grid-cols-3 gap-3', + children: [ + _buildLinkCard( + icon: _iconDocs, + title: 'Documentation', + description: 'Read the Magic Framework docs to get started.', + url: 'https://magic.fluttersdk.com', + ), + _buildLinkCard( + icon: _iconGitHub, + title: 'GitHub', + description: 'Star the repo, report issues, or contribute code.', + url: 'https://github.com/fluttersdk/magic', + ), + _buildLinkCard( + icon: _iconCli, + title: 'CLI Commands', + description: 'Run `magic --help` to see all available commands.', + url: 'https://magic.fluttersdk.com/cli', + ), + ], + ), - const WSpacer(className: 'h-8'), - - // 3. Plan-gate demo: shows the one intended way a gated action is - // surfaced (see UpgradePrompt's own docblock), never a bare error - // toast. `onUpgrade` calls UpgradePrompt.startUpgrade, which routes to - // MagicStarterConfig.billingRoute() with the required tier attached. - // - // In THIS app that tap does nothing visible: only `/` is registered - // (lib/routes/app.dart), the starter ships no billing view, and - // magic's router logs "Route not found" and stays put. That is the - // honest state of a demo, not a bug: a fork registers its own billing - // route (or points magic_starter.routes.billing at one) and the same - // call starts working. Display-only otherwise, with no network call - // and no fake response, since the point is the wiring. - MSUpgradeNudge( - message: 'AI-powered insights are available on the Pro plan.', - requiredPlan: _demoRequiredPlanLabel, - onUpgrade: () => UpgradePrompt.startUpgrade(_demoRequiredPlanId), - ), + const WSpacer(className: 'h-8'), - const WSpacer(className: 'h-8'), + // 3. Plan-gate demo: shows the one intended way a gated action is + // surfaced (see UpgradePrompt's own docblock), never a bare error + // toast. `onUpgrade` calls UpgradePrompt.startUpgrade, which routes to + // MagicStarterConfig.billingRoute() with the required tier attached. + // + // In THIS app that tap does nothing visible: only `/` is registered + // (lib/routes/app.dart), the starter ships no billing view, and + // magic's router logs "Route not found" and stays put. That is the + // honest state of a demo, not a bug: a fork registers its own billing + // route (or points magic_starter.routes.billing at one) and the same + // call starts working. Display-only otherwise, with no network call + // and no fake response, since the point is the wiring. + MSUpgradeNudge( + message: 'AI-powered insights are available on the Pro plan.', + requiredPlan: _demoRequiredPlanLabel, + onUpgrade: () => UpgradePrompt.startUpgrade(_demoRequiredPlanId), + ), - // 4. Footer. - WDiv( - className: 'flex flex-row items-center justify-center gap-1', - children: [ - const WText('Made with', className: 'text-xs text-fg-muted'), - const WIcon(_iconHeart, className: 'text-xs text-destructive'), - const WText('by', className: 'text-xs text-fg-muted'), - WAnchor( - onTap: () => Launch.url('https://anilcancakir.com'), - child: const WText( - 'Anılcan Çakır', - className: 'text-xs font-medium text-fg', - ), + const WSpacer(className: 'h-8'), + + // 4. Footer. + WDiv( + className: 'flex flex-row items-center justify-center gap-1', + children: [ + const WText('Made with', className: 'text-xs text-fg-muted'), + const WIcon(_iconHeart, className: 'text-xs text-destructive'), + const WText('by', className: 'text-xs text-fg-muted'), + WAnchor( + onTap: () => Launch.url('https://anilcancakir.com'), + child: const WText( + 'Anılcan Çakır', + className: 'text-xs font-medium text-fg', ), - ], - ), - ], - ), + ), + ], + ), + ], ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 744ed3d..1d7441a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,7 @@ import Foundation import app_links import desktop_webview_window -import file_picker_darwin +import file_picker import file_selector_macos import flutter_secure_storage_darwin import flutter_timezone diff --git a/test/app/commands/app_rename_command_test.dart b/test/app/commands/app_rename_command_test.dart new file mode 100644 index 0000000..6433fce --- /dev/null +++ b/test/app/commands/app_rename_command_test.dart @@ -0,0 +1,533 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic_example/app/commands/app_rename_command.dart'; + +/// Every identity site `app:rename` owns. The fixture COPIES these out of the +/// real project rather than inventing stand-ins, so a change to any platform +/// file that breaks an anchor fails here instead of in a fork. +const List _fixtureFiles = [ + 'pubspec.yaml', + '.env', + 'DESIGN.md', + '.github/dependabot.yml', + 'lib/main.dart', + 'bin/dispatcher.dart', + 'test/config/wind_token_resolution_test.dart', + 'test/ui/components/recipes_test.dart', + 'android/app/build.gradle.kts', + 'android/app/src/main/AndroidManifest.xml', + 'android/app/src/main/kotlin/com/fluttersdk/magic_example/MainActivity.kt', + 'ios/Runner/Info.plist', + 'ios/Runner.xcodeproj/project.pbxproj', + 'macos/Runner/Info.plist', + 'macos/Runner/Configs/AppInfo.xcconfig', + 'macos/Runner.xcodeproj/project.pbxproj', + 'macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', + 'windows/CMakeLists.txt', + 'windows/runner/Runner.rc', + 'windows/runner/main.cpp', + 'linux/CMakeLists.txt', + 'linux/runner/my_application.cc', + 'web/manifest.json', + 'web/index.html', +]; + +const String _oldKotlinDir = + 'android/app/src/main/kotlin/com/fluttersdk/magic_example'; +const String _newKotlinDir = 'android/app/src/main/kotlin/com/acme/acme_app'; + +void main() { + final temporaries = []; + + tearDown(() { + for (final directory in temporaries) { + if (directory.existsSync()) directory.deleteSync(recursive: true); + } + temporaries.clear(); + }); + + Directory fixture({bool asGitRepository = false}) { + final root = Directory.systemTemp.createTempSync('app_rename_'); + temporaries.add(root); + for (final relative in _fixtureFiles) { + final destination = File('${root.path}/$relative'); + destination.parent.createSync(recursive: true); + destination.writeAsStringSync(File(relative).readAsStringSync()); + } + if (asGitRepository) { + _git(root, const ['init', '--initial-branch=main']); + _git(root, const ['add', '-A']); + _git(root, const [ + '-c', + 'user.email=test@example.com', + '-c', + 'user.name=Test', + 'commit', + '-m', + 'fixture', + ]); + } + return root; + } + + group('app:rename dry run', () { + test('names every identity site and writes nothing', () async { + final root = fixture(); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + dryRun: true, + ); + + expect(result.code, 0); + expect(_snapshot(root), before); + expect(Directory('${root.path}/$_oldKotlinDir').existsSync(), isTrue); + expect(Directory('${root.path}/$_newKotlinDir').existsSync(), isFalse); + + for (final relative in _fixtureFiles) { + if (relative == 'macos/Runner/Info.plist') continue; + expect( + result.output, + contains(relative), + reason: '$relative is missing from the dry-run report', + ); + } + expect(result.output, contains('$_oldKotlinDir -> $_newKotlinDir')); + }); + + test('stays usable while the worktree is dirty', () async { + final root = fixture(asGitRepository: true); + File('${root.path}/pubspec.yaml').writeAsStringSync( + '${File('${root.path}/pubspec.yaml').readAsStringSync()}\n# scratch\n', + ); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + dryRun: true, + ); + + expect(result.code, 0); + expect(result.output, isNot(contains('refused to write'))); + }); + }); + + group('app:rename apply', () { + test('rewrites every identity site', () async { + final root = fixture(); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + expect(result.code, 0); + + String read(String relative) => + File('${root.path}/$relative').readAsStringSync(); + + expect(read('pubspec.yaml'), contains('name: acme_app')); + expect(read('.env'), contains('APP_NAME=Acme App')); + expect(read('DESIGN.md'), contains('name: Acme App')); + expect(read('DESIGN.md'), isNot(contains('Magic Example'))); + expect( + read('.github/dependabot.yml'), + contains('# Dependabot config for fluttersdk/acme_app'), + ); + expect(read('lib/main.dart'), contains("title: 'Acme App'")); + + expect( + read('android/app/build.gradle.kts'), + contains('namespace = "com.acme.acme_app"'), + ); + expect( + read('android/app/build.gradle.kts'), + contains('applicationId = "com.acme.acme_app"'), + ); + expect( + read('android/app/src/main/AndroidManifest.xml'), + contains('android:label="Acme App"'), + ); + + expect(read('ios/Runner/Info.plist'), contains('Acme App<')); + expect(read('ios/Runner/Info.plist'), contains('acme_app<')); + // Apple bundle ids reject underscores, so the package name is camel cased + // for the Apple platforms and only there. + expect( + read('ios/Runner.xcodeproj/project.pbxproj'), + contains('PRODUCT_BUNDLE_IDENTIFIER = com.acme.acmeApp;'), + ); + expect( + read('macos/Runner/Configs/AppInfo.xcconfig'), + allOf( + contains('PRODUCT_NAME = acme_app'), + contains('PRODUCT_BUNDLE_IDENTIFIER = com.acme.acmeApp'), + contains('PRODUCT_COPYRIGHT = '), + contains('com.acme.'), + ), + ); + expect( + read('macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme'), + contains('BuildableName = "acme_app.app"'), + ); + // macos/Runner/Info.plist reads $(PRODUCT_BUNDLE_IDENTIFIER) and + // $(PRODUCT_NAME) from the xcconfig, so a rename must leave it alone. + expect( + read('macos/Runner/Info.plist'), + File('macos/Runner/Info.plist').readAsStringSync(), + ); + + expect( + read('windows/CMakeLists.txt'), + allOf( + contains('project(acme_app LANGUAGES CXX)'), + contains('set(BINARY_NAME "acme_app")'), + ), + ); + expect( + read('windows/runner/main.cpp'), + contains('window.Create(L"Acme App"'), + ); + expect( + read('windows/runner/Runner.rc'), + allOf( + contains('VALUE "CompanyName", "com.acme"'), + contains('VALUE "FileDescription", "Acme App"'), + contains('VALUE "InternalName", "acme_app"'), + contains('VALUE "OriginalFilename", "acme_app.exe"'), + contains('VALUE "ProductName", "Acme App"'), + contains('2026 com.acme.'), + ), + ); + + expect( + read('linux/CMakeLists.txt'), + allOf( + contains('set(BINARY_NAME "acme_app")'), + contains('set(APPLICATION_ID "com.acme.acme_app")'), + ), + ); + expect( + read('linux/runner/my_application.cc'), + contains('gtk_header_bar_set_title(header_bar, "Acme App")'), + ); + + expect(read('web/manifest.json'), contains('"name": "Acme App"')); + expect(read('web/manifest.json'), contains('"short_name": "Acme App"')); + expect(read('web/index.html'), contains('Acme App')); + + expect( + read('bin/dispatcher.dart'), + contains('package:acme_app/app/commands/_index.g.dart'), + ); + expect( + read('test/ui/components/recipes_test.dart'), + contains('package:acme_app/ui/components/tag/tag.recipe.dart'), + ); + + // Nothing may still carry the old identity anywhere this command owns. + for (final relative in _fixtureFiles) { + if (relative == 'macos/Runner/Info.plist') continue; + if (relative == 'macos/Runner.xcodeproj/project.pbxproj') continue; + // MainActivity.kt lives under the directory the rename just moved. + final path = relative.replaceFirst(_oldKotlinDir, _newKotlinDir); + expect( + read(path), + isNot(contains('magic_example')), + reason: '$path still carries the old package name', + ); + } + }); + + test( + 'moves the Kotlin package directory, not just its package line', + () async { + final root = fixture(); + + await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(Directory('${root.path}/$_oldKotlinDir').existsSync(), isFalse); + // The emptied organisation levels go too: git does not track empty + // directories, so leaving them would make a fresh clone differ. + expect( + Directory( + '${root.path}/android/app/src/main/kotlin/com/fluttersdk', + ).existsSync(), + isFalse, + ); + expect( + File( + '${root.path}/$_newKotlinDir/MainActivity.kt', + ).readAsStringSync(), + contains('package com.acme.acme_app'), + ); + }, + ); + + test( + 'touches only anchored PRODUCT_BUNDLE_IDENTIFIER lines in a pbxproj', + () async { + final root = fixture(); + const decoys = + '\t\t\t\t/* com.fluttersdk.magicExample is only a comment */\n' + '\t\t\t\tINFOPLIST_KEY_CFBundleName = com.fluttersdk.magicExample;\n'; + final pbxproj = File( + '${root.path}/ios/Runner.xcodeproj/project.pbxproj', + ); + pbxproj.writeAsStringSync('${pbxproj.readAsStringSync()}$decoys'); + + await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + final updated = pbxproj.readAsStringSync(); + expect(updated, contains(decoys)); + expect( + updated, + isNot(contains('PRODUCT_BUNDLE_IDENTIFIER = com.fluttersdk')), + ); + // The `.RunnerTests` suffix rides along instead of being flattened. + expect( + updated, + contains('PRODUCT_BUNDLE_IDENTIFIER = com.acme.acmeApp.RunnerTests;'), + ); + }, + ); + + test('a second run with the same arguments changes nothing', () async { + final root = fixture(); + + await _run(root, name: 'acme_app', org: 'com.acme', display: 'Acme App'); + final afterFirst = _snapshot(root); + + final second = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(second.code, 0); + expect(second.output, contains('changed (0)')); + expect(_snapshot(root), afterFirst); + }); + }); + + group('app:rename refusals', () { + test('rejects a --name that is not a Dart package identifier', () async { + for (final invalid in const ['Acme App', '../evil', 'com.acme', '9app']) { + final root = fixture(); + final before = _snapshot(root); + + final result = await _run( + root, + name: invalid, + org: 'com.acme', + display: 'Acme App', + ); + + expect(result.code, 1, reason: 'accepted --name=$invalid'); + expect(result.output, contains('app:rename refused --name')); + expect(_snapshot(root), before); + } + }); + + test('rejects an --org that is not reverse DNS', () async { + for (final invalid in const ['acme', '../../etc', 'com..acme', 'Com.A']) { + final root = fixture(); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: invalid, + display: 'Acme App', + ); + + expect(result.code, 1, reason: 'accepted --org=$invalid'); + expect(result.output, contains('app:rename refused --org')); + expect(_snapshot(root), before); + } + }); + + test( + 'rejects a --display that would break out of a string literal', + () async { + for (final invalid in const [ + 'Acme "App"', + r'Acme\App', + 'A', + r'$App', + ]) { + final root = fixture(); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: invalid, + ); + + expect(result.code, 1, reason: 'accepted --display=$invalid'); + expect(result.output, contains('app:rename refused --display')); + expect(_snapshot(root), before); + } + }, + ); + + test('refuses when no identity option is given', () async { + final root = fixture(); + + final result = await _run(root); + + expect(result.code, 1); + expect( + result.output, + contains('needs at least one of --name, --org or --display'), + ); + }); + + test('refuses to write into a dirty worktree', () async { + final root = fixture(asGitRepository: true); + final env = File('${root.path}/.env'); + env.writeAsStringSync('${env.readAsStringSync()}\nSCRATCH=1\n'); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(result.code, 1); + expect(result.output, contains('app:rename refused to write')); + expect(result.output, contains('uncommitted change')); + expect(_snapshot(root), before); + }); + + test( + 'does not count a modified pubspec.lock or an untracked file as dirty', + () async { + final root = fixture(asGitRepository: true); + // pubspec.lock is tracked and permanently modified in this workspace: the + // index holds the hosted-only resolution, a local `flutter pub get` + // rewrites the working copy with sibling paths. + File('${root.path}/pubspec.lock').writeAsStringSync('# committed\n'); + _git(root, const ['add', 'pubspec.lock']); + _git(root, const [ + '-c', + 'user.email=test@example.com', + '-c', + 'user.name=Test', + 'commit', + '-m', + 'lock', + ]); + File('${root.path}/pubspec.lock').writeAsStringSync('# local paths\n'); + File('${root.path}/scratch.txt').writeAsStringSync('untracked\n'); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(result.code, 0); + expect(result.output, isNot(contains('refused to write'))); + expect( + File('${root.path}/pubspec.yaml').readAsStringSync(), + contains('name: acme_app'), + ); + }, + ); + + test( + 'refuses when the Android namespace does not match the package name', + () async { + final root = fixture(); + final gradle = File('${root.path}/android/app/build.gradle.kts'); + gradle.writeAsStringSync( + gradle.readAsStringSync().replaceFirst( + 'namespace = "com.fluttersdk.magic_example"', + 'namespace = "com.fluttersdk.somethingelse"', + ), + ); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(result.code, 1); + expect(result.output, contains('does not end in ".magic_example"')); + expect(_snapshot(root), before); + }, + ); + }); +} + +/// Invokes the command against [root] and returns its exit code plus the whole +/// buffered output (errors included, prefixed with `[ERROR]`). +Future<({int code, String output})> _run( + Directory root, { + String? name, + String? org, + String? display, + bool dryRun = false, +}) async { + final output = BufferedOutput(); + final code = await AppRenameCommand(root: root).handle( + ArtisanContext.bare( + MapInput({ + 'name': ?name, + 'org': ?org, + 'display': ?display, + 'dry-run': dryRun, + }), + output, + ), + ); + return (code: code, output: output.content); +} + +/// Path to content for every file under [root], excluding git's own storage. +Map _snapshot(Directory root) { + final snapshot = {}; + for (final entity in root.listSync(recursive: true)) { + if (entity is! File) continue; + final relative = entity.path.substring(root.path.length + 1); + if (relative.startsWith('.git/')) continue; + snapshot[relative] = entity.readAsStringSync(); + } + return snapshot; +} + +void _git(Directory root, List arguments) { + final result = Process.runSync('git', arguments, workingDirectory: root.path); + if (result.exitCode != 0) { + throw StateError('git ${arguments.join(' ')} failed: ${result.stderr}'); + } +} From eaf0fc427fb916660a886c561e6232fe4d2867a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 7 Sep 2026 02:12:26 +0300 Subject: [PATCH 06/10] Act on the review: four false accessibility claims, two silent data paths The status tokens asserted WCAG AA in their comments and missed it on four of six pairs, measured: white on #16A34A is 3.30, near-black on #B45309 is 3.53, white on #0EA5E9 is 2.77, white on #0284C7 is 4.10. The cause was assuming one foreground per role works in both modes, but the generated fills do not hold lightness across modes and they move in opposite directions: bg-success goes darker to lighter, bg-warning lighter to darker. So each foreground flips by mode now, no generated fill changed, and every pair is recomputed in the docblock: 5.93, 6.40, 5.02, 5.38, 5.57, 5.02. Two paths could act without the operator seeing it. The identity read back out of the tree was never validated, only the flags were, so a pubspec.yaml carrying a traversal in its name reached a directory move; it is now held to the same patterns. And the Kotlin package move skipped every non-File entity and then deleted the source recursively, which would take a fork's subpackages with it, silently, in a case this repo has no fixture for. --display now rejects # and tab. They break nothing syntactically, they get eaten: flutter_dotenv strips a trailing #comment, so --display='Acme #1' left the app reading 'Acme' while the command's own re-read saw the whole string, which quietly breaks idempotency. TEST_HOST on macOS is rewritten after all. The constraint was to touch the Xcode project only through anchored matches, and this rule anchors on the setting name and the current value exactly like the bundle-id rule beside it, so it is an instance of that constraint rather than an exception to it. Left alone it pointed a renamed fork's RunnerTests target at a bundle that is no longer produced. The lockfile gate did not run in CI at all, because CI never invokes bin/check; it now checks the committed lock directly, before pub get rewrites the evidence. The dashboard card was duplicated between its loading and loaded branches, differing only in indentation, and its two strings were hardcoded English on the one screen the rules name as the pattern to copy. --- .claude/agents/component-visual-reviewer.md | 2 +- .github/workflows/ci.yml | 21 +++++ .gitignore | 7 +- README.md | 12 ++- assets/lang/en.json | 4 + assets/lang/tr.json | 4 + bin/check | 15 +++- lib/app/commands/app_rename_command.dart | 80 +++++++++++++++++-- lib/app/controllers/dashboard_controller.dart | 20 +++-- lib/config/example_status_tokens.dart | 55 +++++++++---- lib/resources/views/dashboard_view.dart | 61 +++++++------- .../app/commands/app_rename_command_test.dart | 54 +++++++++++++ 12 files changed, 268 insertions(+), 67 deletions(-) diff --git a/.claude/agents/component-visual-reviewer.md b/.claude/agents/component-visual-reviewer.md index eb2cf3f..8d3039b 100644 --- a/.claude/agents/component-visual-reviewer.md +++ b/.claude/agents/component-visual-reviewer.md @@ -18,7 +18,7 @@ You receive: - `screenshot_light`: path to a JPEG/PNG screenshot of the component in light mode - `screenshot_dark`: path to a JPEG/PNG screenshot of the component in dark mode -- `design_md`: path to the DESIGN.md file (default: `magic_example/DESIGN.md`) +- `design_md`: path to the DESIGN.md file (default: `DESIGN.md`, repo-relative like every other path in this file) - `component`: name of the component or screen being reviewed --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7ea041..dc27dd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,19 @@ jobs: with: channel: stable cache: true + # bin/check's run_lockfile never runs in CI, because CI does not invoke + # bin/check at all: it runs the individual tools. Without this step the + # only thing standing between a path-poisoned lock and main is whether a + # developer chose to run the gate locally, and the symptom in CI would be + # a pub resolution error naming a directory nobody recognises. Before + # `flutter pub get`, since that would rewrite the evidence. + - name: Committed lock is hosted-only + run: | + if grep -qE 'source: (path|git)' pubspec.lock; then + echo "pubspec.lock carries local sibling paths. Regenerate it with pubspec_overrides.yaml moved aside." >&2 + grep -nE 'source: (path|git)' pubspec.lock >&2 + exit 1 + fi - name: Resolve dependencies run: flutter pub get - name: Analyze @@ -137,6 +150,14 @@ jobs: cache: true - name: Resolve against pub.dev run: flutter pub get + # The lock is TRACKED, so a resolve that rewrites it means the committed + # lock disagreed with pubspec.yaml. CI would otherwise repair that in + # place and inspect the repaired copy, so the drift never surfaces and a + # fork keeps cloning a lock nothing has verified. bin/check's run_lockfile + # cannot see this: it reads the index on a developer's machine, and there + # is no index here. + - name: Assert the committed lock matches what pub.dev resolves + run: git diff --exit-code -- pubspec.lock - name: Analyze against the published siblings run: flutter analyze # `flutter pub get` exits 0 on a downgrade as long as SOME version in the diff --git a/.gitignore b/.gitignore index 6e1b3f3..493316d 100644 --- a/.gitignore +++ b/.gitignore @@ -57,9 +57,10 @@ pubspec_overrides.yaml /REPORT.md # pubspec.lock IS committed here, reversing this project's earlier policy of -# ignoring it. That argument was correct about the failure mode (a lock generated with -# pubspec_overrides.yaml active encodes `path: "../magic"` and friends, paths -# that do not exist in a fork) but drew the wrong conclusion from it: the fix +# ignoring it. That argument was correct about the failure mode (a lock generated +# with pubspec_overrides.yaml active encodes `source: path` entries carrying the +# ABSOLUTE sibling paths that file holds, which exist on exactly one machine) +# but drew the wrong conclusion from it: the fix # for a lock generated the WRONG way is to generate it the right way, not to # stop tracking it and hand every fork a first build that resolves whatever # the caret ranges in pubspec.yaml happen to solve to on the day it is cloned diff --git a/README.md b/README.md index 7247210..46a5b44 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,18 @@ anything. It refuses to run while writing against a dirty worktree, and running it twice with the same arguments is a no-op. It owns every identity site measured in this app: the Dart package name and -its five `import 'package:magic_example/...'` sites; the Android namespace, +every `import 'package:magic_example/...'` site under `lib/`, `test/` and +`bin/`; the Android namespace, `applicationId`, display name, and the Kotlin package directory move (`android/app/src/main/kotlin/com/fluttersdk/magic_example/` moves to the new package path); the iOS bundle identifier and display name in `project.pbxproj` and `Info.plist`; the macOS bundle identifier and display name across -`AppInfo.xcconfig`, `project.pbxproj`, and `Runner.xcscheme` (macOS's own +`AppInfo.xcconfig`, `project.pbxproj`, and `Runner.xcscheme`, plus the +`TEST_HOST` the macOS `RunnerTests` target launches against (macOS's own `Info.plist` needs no edit, it reads `$(PRODUCT_BUNDLE_IDENTIFIER)` and -`$(PRODUCT_NAME)`); the Windows `CMakeLists.txt`, `Runner.rc`, and `main.cpp`; +`$(PRODUCT_NAME)`; three product-reference labels in +`macos/.../project.pbxproj` keep the old name and are cosmetic, Xcode +regenerates them); the Windows `CMakeLists.txt`, `Runner.rc`, and `main.cpp`; the Linux `CMakeLists.txt` and `my_application.cc`; the web `manifest.json` and `index.html` title; the `APP_NAME` key in `.env` (not its values, see step 2); `lib/main.dart`; the app name reference in `DESIGN.md` (not its tokens, @@ -46,7 +50,7 @@ see step 4); and `.github/dependabot.yml`. `app:rename` only rewrites the `APP_NAME` key; it does not touch `API_URL` or any other value. Point `API_URL` at the new backend, then run -`magic key:generate` if the app uses the `Crypt` facade. `.env` is COMMITTED +`dart run bin/dispatcher.dart key:generate` if the app uses the `Crypt` facade. `.env` is COMMITTED here and bundled as a Flutter asset in `pubspec.yaml`, which is deliberate on both counts: `flutter_dotenv` can only load it on web when it is a bundled asset, and a bundled asset that does not exist fails `flutter build`, so diff --git a/assets/lang/en.json b/assets/lang/en.json index 3c40dfe..6f7489e 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -91,6 +91,10 @@ "privacy_policy": "Privacy Policy", "legal_and": "and" }, + "dashboard": { + "loading": "Loading your dashboard...", + "welcome_back": "Welcome back, :name" + }, "errors": { "unexpected": "An unexpected error occurred. Please try again.", "network_error": "Network connection failed. Please check your internet connection and try again.", diff --git a/assets/lang/tr.json b/assets/lang/tr.json index afca553..5e9b1c0 100644 --- a/assets/lang/tr.json +++ b/assets/lang/tr.json @@ -91,6 +91,10 @@ "privacy_policy": "Gizlilik Politikası", "legal_and": "ve" }, + "dashboard": { + "loading": "Panonuz yükleniyor...", + "welcome_back": "Tekrar hoş geldiniz, :name" + }, "errors": { "unexpected": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", "network_error": "Ağ bağlantısı başarısız oldu. Lütfen internet bağlantınızı kontrol edip tekrar deneyin.", diff --git a/bin/check b/bin/check index c3a85ce..8f7a26c 100755 --- a/bin/check +++ b/bin/check @@ -201,11 +201,24 @@ run_lockfile() { echo "pubspec.lock is not tracked; commit the hosted-only lock" >&2 return 1 } + # A positive control before the negative test. `grep -q` exits 1 on EMPTY + # input exactly as it does on clean input, so a staged lock that is empty or + # truncated would sail through the check below reporting "no local paths". + # The whole job of this gate is to notice a bad lock, and a zero-byte one is + # the worst kind. + if [ "$(printf '%s' "$staged" | grep -c 'sdks:')" -eq 0 ]; then + echo "pubspec.lock is staged but carries no 'sdks:' block, so it is empty" >&2 + echo "or truncated rather than clean. Regenerate it hosted-only." >&2 + return 1 + fi # `source: path` rather than a relative `path: "../"`. The overrides file holds # ABSOLUTE paths on purpose, so a poisoned lock reads `path: "/Users/..."` with # `relative: false` and a relative-path pattern would match it zero times. The # source line is the categorical test and covers both forms. - if printf '%s' "$staged" | grep -q 'source: path'; then + # `git` as well as `path`: bin/parse-overrides-test.py:78-91 covers a git + # override as a supported shape, and one resolves to `source: git` with a + # ref nobody else can fetch, which is the same class of unbuildable lock. + if printf '%s' "$staged" | grep -qE 'source: (path|git)'; then echo "pubspec.lock encodes local sibling paths. Regenerate it hosted-only:" >&2 echo " mv pubspec_overrides.yaml /tmp/ && flutter pub get && git add pubspec.lock" >&2 echo " mv /tmp/pubspec_overrides.yaml ." >&2 diff --git a/lib/app/commands/app_rename_command.dart b/lib/app/commands/app_rename_command.dart index abe2822..d3b1eb4 100644 --- a/lib/app/commands/app_rename_command.dart +++ b/lib/app/commands/app_rename_command.dart @@ -40,7 +40,14 @@ class AppRenameCommand extends ArtisanCommand { /// title, three C string literals and a single-quoted Dart literal. The /// rejected characters are exactly the ones that would break out of one of /// those quoting contexts. - static final RegExp _displayPattern = RegExp(r'''^[^"'\\<>&$\r\n]+$'''); + /// `#` and a tab are here for a different reason than the quoting + /// metacharacters: they do not break a literal, they get EATEN. The display + /// name is written unquoted into `.env`, and `flutter_dotenv` strips + /// `#[^'"]*$` as a trailing comment, so `--display="Acme #1"` leaves the + /// running app reading `Acme` while this command's own re-read of `.env` + /// still sees the whole string, which breaks idempotency silently. A tab + /// does the same to the JSON string in `web/manifest.json`. + static final RegExp _displayPattern = RegExp(r'''^[^"'\\<>&$#\t\r\n]+$'''); @override String get signature => @@ -104,6 +111,30 @@ class AppRenameCommand extends ArtisanCommand { final _Identity? current = _readCurrentIdentity(ctx); if (current == null) return 1; + // The CURRENT identity is read out of the tree, so it is no more trusted + // than the flags above: it reaches `kotlinPath`, and from there a read, a + // write and a `deleteSync(recursive: true)`. `org` survives a hostile value + // because `kotlinPath` splits it on `.`, but `package` is appended whole, + // so a `pubspec.yaml` carrying `name: ../../../tmp/x` with a matching + // gradle namespace would build a move endpoint outside the project. Held to + // the same patterns rather than trusted for having come off disk. + if (!_namePattern.hasMatch(current.package)) { + ctx.output.error( + 'app:rename refused the package name it read from pubspec.yaml ' + '("${current.package}"): it must match ${_namePattern.pattern}. ' + 'This value becomes a Kotlin package directory.', + ); + return 1; + } + if (!_orgPattern.hasMatch(current.org)) { + ctx.output.error( + 'app:rename refused the org it read from android/app/build.gradle.kts ' + '("${current.org}"): it must match ${_orgPattern.pattern}. ' + 'Every segment becomes a Kotlin package directory level.', + ); + return 1; + } + final target = _Identity( package: name ?? current.package, org: org ?? current.org, @@ -427,6 +458,23 @@ class AppRenameCommand extends ArtisanCommand { RegExp('PRODUCT_BUNDLE_IDENTIFIER = $oldAppleId([^;\n]*);'), (m) => 'PRODUCT_BUNDLE_IDENTIFIER = ${to.appleId}${m[1]};', ), + // The RunnerTests target's host application, and the one line in this + // file that is functional rather than cosmetic: leave it and a renamed + // fork's macOS test target cannot launch, because it looks for a bundle + // no longer produced. Anchored on the setting name AND the current + // value, the same shape as the bundle-id rule above, so it cannot + // touch an object id or a comment. There is no iOS counterpart on + // purpose: `ios/.../project.pbxproj` names `Runner.app` there, which is + // not the package name and does not move. + _Rule( + RegExp( + 'TEST_HOST = "\\\$\\(BUILT_PRODUCTS_DIR\\)/' + '${RegExp.escape(from.package)}\\.app/([^"]*)"', + ), + (m) => + 'TEST_HOST = "\$(BUILT_PRODUCTS_DIR)/${to.package}.app/' + '${m[1]!.replaceAll(from.package, to.package)}"', + ), ]), _FileRewrite( 'macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', @@ -600,9 +648,12 @@ class AppRenameCommand extends ArtisanCommand { out.writeln('not owned by app:rename, do these by hand:'); out.writeln(' - launcher icons on every platform'); out.writeln( - ' - macos/Runner.xcodeproj/project.pbxproj product references and ' - 'TEST_HOST still name "${from.package}.app"; only anchored ' - 'PRODUCT_BUNDLE_IDENTIFIER lines are rewritten there', + ' - macos/Runner.xcodeproj/project.pbxproj still names ' + '"${from.package}.app" in its PBXFileReference and group entries, which ' + 'are cosmetic labels Xcode regenerates. TEST_HOST and the bundle ids ARE ' + 'rewritten, so the RunnerTests target still launches; `flutter build ' + 'macos` was never affected either way, since the shell phase derives the ' + 'app filename from \$PRODUCT_NAME at build time', ); out.writeln(' - README.md and AGENTS.md prose'); out.writeln( @@ -624,9 +675,26 @@ class AppRenameCommand extends ArtisanCommand { final destination = Directory(_absolute(move.destination)); destination.createSync(recursive: true); for (final entity in source.listSync()) { - if (entity is! File) continue; final name = _relative(entity.path).split('/').last; - entity.renameSync('${destination.path}/$name'); + final String into = '${destination.path}/$name'; + // Subdirectories move too. This loop used to skip every non-File and + // the `deleteSync(recursive: true)` below then destroyed what it + // skipped, silently. The boilerplate's own package holds only + // MainActivity.kt so nothing here could catch it, but a fork with a + // real Android package almost certainly has subpackages + // (`receivers/`, `workers/`), and losing them to a rename is not a + // failure anyone would connect back to this command. + if (entity is File) { + entity.renameSync(into); + } else if (entity is Directory) { + entity.renameSync(into); + } else { + throw StateError( + 'app:rename found ${entity.runtimeType} at ${entity.path} inside ' + 'the Kotlin package directory and will not move it. Move or ' + 'remove it by hand, then re-run.', + ); + } } source.deleteSync(recursive: true); _pruneEmptyParents(source.parent, 'android/app/src/main/kotlin'); diff --git a/lib/app/controllers/dashboard_controller.dart b/lib/app/controllers/dashboard_controller.dart index 228979f..258db45 100644 --- a/lib/app/controllers/dashboard_controller.dart +++ b/lib/app/controllers/dashboard_controller.dart @@ -30,12 +30,18 @@ import '../models/user.dart'; /// This boilerplate ships no dashboard endpoint, so there is no network call /// to await. The single `await` in [load] is not a `Future.delayed` standing /// in for one: it is the same yield every other controller in this codebase -/// gets for free from its first `await Http.get(...)`, kept here so -/// [resetForSession]'s clear-then-refetch contract is real (the view actually -/// paints the cleared state before the refetch lands) rather than a same-tick -/// no-op indistinguishable from skipping the interface entirely. A fork -/// wiring a real fetch (a `/dashboard` summary, say) replaces the body of -/// [load] without touching [onInit] or [resetForSession]. +/// gets for free from its first `await Http.get(...)`, kept so the shape a +/// fork copies is the shape a real fetch needs. +/// +/// Be precise about what that yield does and does not buy, because the first +/// version of this docblock overclaimed it. Awaiting an already-completed +/// future resumes on the MICROTASK queue, and microtasks drain before the +/// scheduler paints, so no frame is ever rendered between [setEmpty] and +/// [setSuccess] on the [resetForSession] path. The yield orders the two +/// notifications; it does not produce a visible cleared state. With a real +/// `await Http.get(...)` in [load] that changes on its own, because a network +/// round trip does cross a frame boundary. A fork wiring a real fetch +/// replaces the body of [load] without touching [onInit] or [resetForSession]. class DashboardController extends MagicController with MagicStateMixin implements SessionScopedController { @@ -72,7 +78,7 @@ class DashboardController extends MagicController /// resolves, which is exactly the wrong default across an identity change. @override Future resetForSession() async { - setState(null, status: const RxStatus.empty()); + setEmpty(); await load(); } } diff --git a/lib/config/example_status_tokens.dart b/lib/config/example_status_tokens.dart index 5c1027e..6d16979 100644 --- a/lib/config/example_status_tokens.dart +++ b/lib/config/example_status_tokens.dart @@ -8,27 +8,52 @@ /// for either `success` or `warning` (compare `bg-destructive`, which DOES get /// a `text-on-destructive` peer). Those gaps are hand-authored here, in the /// same `' dark:'` className-string shape as `supplementAliases`, -/// and merged in `lib/main.dart` rather than regenerated. +/// and merged in `lib/config/wind_theme.dart` (not `lib/main.dart`, so the +/// token guard in `test/config/` can ask the same theme the app runs on). +/// +/// ### Every foreground here flips by mode, and that is not a style choice +/// +/// The generated fills do not keep a constant lightness across modes: +/// `bg-success` goes `#15803D` light to the LIGHTER `#16A34A` dark, while +/// `bg-warning` goes `#D97706` light to the DARKER `#B45309` dark. So the +/// foreground that clears WCAG AA (4.5:1 for normal text) is white on one side +/// and near-black on the other, in opposite directions for the two roles. A +/// single foreground for both modes fails AA on one of them every time, which +/// is what the first version of this file shipped: white on `#16A34A` measures +/// 3.30 and near-black on `#B45309` measures 3.53. +/// +/// Measured ratios, all against the fills actually in play: +/// +/// text-on-info #FFFFFF on #0369A1 5.93 #111827 on #0EA5E9 6.40 +/// text-on-success #FFFFFF on #15803D 5.02 #111827 on #16A34A 5.38 +/// text-on-warning #111827 on #D97706 5.57 #FFFFFF on #B45309 5.02 +/// +/// Recompute these before changing any hex here or any `success`/`warning` +/// entry in DESIGN.md; the two files are coupled through these pairs and +/// `design:sync` regenerates one of them. See +/// `docs/design-culture/accessibility-wcag.md`. const Map exampleStatusAliases = { // bg-info: an informational tone. DESIGN.md's `colors` block defines no - // `info` role at all, so `design:sync` has nothing to emit for it. Sky - // blue, distinct from both the violet brand and the indigo accent so an - // info banner does not read as a brand action. - 'bg-info': 'bg-[#0284C7] dark:bg-[#0EA5E9]', + // `info` role at all, so `design:sync` has nothing to emit for it. Sky blue, + // distinct from both the violet brand and the indigo accent so an info + // banner does not read as a brand action. The light fill is sky-700 rather + // than sky-600 because white on sky-600 measures 4.10 and misses AA. + 'bg-info': 'bg-[#0369A1] dark:bg-[#0EA5E9]', // text-on-info: the foreground that sits ON the solid `bg-info` fill, the // same role `text-on-primary`/`text-on-destructive` carry for their own - // backgrounds. White in both modes: the solid tone is dark enough at both - // hexes for white text to clear WCAG AA. - 'text-on-info': 'text-[#FFFFFF] dark:text-[#FFFFFF]', + // backgrounds. White on the dark light-mode fill, near-black on the lighter + // dark-mode one. + 'text-on-info': 'text-[#FFFFFF] dark:text-[#111827]', // text-on-success: `design:sync` emits `bg-success` but no foreground peer - // for it (unlike `bg-destructive`, which gets `text-on-destructive`). White - // clears AA against both success hexes. - 'text-on-success': 'text-[#FFFFFF] dark:text-[#FFFFFF]', + // for it (unlike `bg-destructive`, which gets `text-on-destructive`). The + // dark-mode fill `#16A34A` is the LIGHTER of the two, so it takes the dark + // foreground. + 'text-on-success': 'text-[#FFFFFF] dark:text-[#111827]', - // text-on-warning: same gap as `success` above, but with a dark foreground - // rather than white: amber is too light for white text to clear AA the way - // the darker info/success/destructive tones do. - 'text-on-warning': 'text-[#111827] dark:text-[#111827]', + // text-on-warning: the same gap as `success`, flipped. Amber `#D97706` is + // too light for white, and the dark-mode `#B45309` is dark enough that white + // is the only one of the two that clears AA there. + 'text-on-warning': 'text-[#111827] dark:text-[#FFFFFF]', }; diff --git a/lib/resources/views/dashboard_view.dart b/lib/resources/views/dashboard_view.dart index 263f27e..a985272 100644 --- a/lib/resources/views/dashboard_view.dart +++ b/lib/resources/views/dashboard_view.dart @@ -57,12 +57,35 @@ class _DashboardViewState /// them sends `?upgrade=Pro` where the catalog expects `pro`. static const _demoRequiredPlanLabel = 'Pro'; + /// Shared by the loading and loaded branches so the card cannot drift. + /// + /// These were duplicated inline in both builders and already differed by + /// indentation, which means the two strings were not even byte-identical: + /// wind collapses the whitespace so they rendered the same, and a change to + /// one would have silently stopped matching the other. + static const String _cardClass = ''' + rounded-2xl bg-surface-container + border border-color-border + p-6 lg:p-8 flex flex-col items-center + '''; + + static const String _heroClass = ''' + w-20 h-20 rounded-2xl + flex items-center justify-center + bg-primary + '''; + + static const Widget _hero = WDiv( + className: _heroClass, + child: WIcon(_iconHero, className: 'text-4xl text-on-primary'), + ); + @override void initState() { // Registers the controller before the base class resolves it, matching // the canonical pairing (see DashboardController's docblock): this view // is the controller's only backer, so nothing else would ever create it. - Magic.findOrPut(DashboardController.new); + DashboardController.instance; super.initState(); } @@ -85,23 +108,12 @@ class _DashboardViewState /// once the name lands; only the hero icon and a status line render. Widget _buildSkeleton() { return WDiv( - className: ''' - rounded-2xl bg-surface-container - border border-color-border - p-6 lg:p-8 flex flex-col items-center - ''', + className: _cardClass, children: [ - WDiv( - className: ''' - w-20 h-20 rounded-2xl - flex items-center justify-center - bg-primary - ''', - child: const WIcon(_iconHero, className: 'text-4xl text-on-primary'), - ), + _hero, const WSpacer(className: 'h-6'), - const MSTypography( - 'Loading your dashboard...', + MSTypography( + trans('dashboard.loading'), variant: TypographyVariant.caption, ), ], @@ -111,21 +123,10 @@ class _DashboardViewState /// The card's content once [DashboardController] has resolved a greeting. Widget _buildLoaded({required String appName, required String greetingName}) { return WDiv( - className: ''' - rounded-2xl bg-surface-container - border border-color-border - p-6 lg:p-8 flex flex-col items-center - ''', + className: _cardClass, children: [ // 1. Hero. - WDiv( - className: ''' - w-20 h-20 rounded-2xl - flex items-center justify-center - bg-primary - ''', - child: const WIcon(_iconHero, className: 'text-4xl text-on-primary'), - ), + _hero, const WSpacer(className: 'h-6'), MSTypography( appName, @@ -134,7 +135,7 @@ class _DashboardViewState ), const WSpacer(className: 'h-2'), MSTypography( - 'Welcome back, $greetingName', + trans('dashboard.welcome_back', {'name': greetingName}), variant: TypographyVariant.caption, ), diff --git a/test/app/commands/app_rename_command_test.dart b/test/app/commands/app_rename_command_test.dart index 6433fce..2b22ccf 100644 --- a/test/app/commands/app_rename_command_test.dart +++ b/test/app/commands/app_rename_command_test.dart @@ -328,6 +328,60 @@ void main() { expect(second.output, contains('changed (0)')); expect(_snapshot(root), afterFirst); }); + + test('a new display name containing the old one applies exactly once', () async { + // The hazard the DESIGN.md prose rule is ordered against: that rule is + // the one unanchored rewrite in the set, so with the rules in the + // obvious order "Magic Example" to "Magic Example Deluxe" rewrites the + // already-correct frontmatter a second time and yields + // "Magic Example Deluxe Deluxe". Nothing else exercised it. + final root = fixture(); + + final result = await _run(root, display: 'Magic Example Deluxe'); + + expect(result.code, 0); + final design = File('${root.path}/DESIGN.md').readAsStringSync(); + expect(design, contains('Magic Example Deluxe')); + expect(design, isNot(contains('Magic Example Deluxe Deluxe'))); + expect(design, isNot(contains('Deluxe Deluxe'))); + }); + + test('runs with --display alone, leaving package and org untouched', () async { + // The likeliest fork invocation after the first rename, and the case + // where `from.package == to.package`, so every package-keyed rule must + // be a no-op rather than a rewrite-to-itself that reports a change. + final root = fixture(); + final pubspecBefore = File('${root.path}/pubspec.yaml').readAsStringSync(); + + final result = await _run(root, display: 'Acme App'); + + expect(result.code, 0); + expect(File('${root.path}/pubspec.yaml').readAsStringSync(), pubspecBefore); + expect( + File('${root.path}/android/app/build.gradle.kts').readAsStringSync(), + contains('com.fluttersdk.magic_example'), + ); + }); + + test('rewrites the macOS RunnerTests host application', () async { + // TEST_HOST is the only functional line in the macOS project file. Left + // stale it points at a bundle the renamed fork no longer produces, and + // the RunnerTests target cannot launch. The assertion covers both + // occurrences of the package inside the value, not just the .app. + final root = fixture(); + + await _run(root, name: 'acme_app', org: 'com.acme', display: 'Acme App'); + + final pbx = File( + '${root.path}/macos/Runner.xcodeproj/project.pbxproj', + ).readAsStringSync(); + expect(pbx, contains(r'TEST_HOST = "$(BUILT_PRODUCTS_DIR)/acme_app.app/')); + expect(pbx, isNot(contains(r'/magic_example.app/'))); + expect( + pbx, + isNot(contains(r'$(BUNDLE_EXECUTABLE_FOLDER_PATH)/magic_example"')), + ); + }); }); group('app:rename refusals', () { From 8e8422b096e3a6576b89fc0792aa458943a768e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 7 Sep 2026 02:27:30 +0300 Subject: [PATCH 07/10] Let magic decide file_picker, instead of pinning it twice Nothing in this app imports file_picker. It arrives through magic's Pick facade, which makes the pin here a second opinion on a version this app has no stake in, and second opinions on a transitive dependency only matter when they disagree with the first one. They did. magic's master moved to ^12.2.0 ahead of a release while this app pinned ^11.0.2 to match published magic 0.0.9. Those ranges do not intersect, so version solving failed outright against local sibling checkouts and bin/check could not run at all, which is the state this repository was already in before any of this work started. Removing the pin resolves file_picker from whichever magic is in play: transitive 12.2.0 against the path-bound local checkout, transitive 11.0.3 against published magic 0.0.9, which is the same version a fork was getting before. Both graphs verified green with the full suite, and the day magic publishes its file_picker 12 port nothing here needs editing. The comment the pin carried argued a fork has no dependency_overrides safety net so the constraint should be repeated here. That was true and still insufficient: magic 0.0.9 already caps file_picker below 12.0.0-0, so the pin never protected anything the framework was not already protecting. --- pubspec.lock | 2 +- pubspec.yaml | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 1466300..2c05652 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -226,7 +226,7 @@ packages: source: hosted version: "7.0.1" file_picker: - dependency: "direct main" + dependency: transitive description: name: file_picker sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" diff --git a/pubspec.yaml b/pubspec.yaml index 2306aac..4fe4ba7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,12 +51,15 @@ dependencies: cupertino_icons: ^1.0.8 fluttersdk_artisan: ^0.0.14 - # Pin as a real dependency, not a workspace-only override: magic's own - # file_picker constraint (">=11.0.2 <12.0.0-0") already excludes the - # 12.0.0-beta prerelease, but a fork resolving purely from pub.dev has no - # dependency_overrides safety net, so pin it here too. The beta's non-null - # saveFile params break magic. - file_picker: ^11.0.2 + # No file_picker pin here on purpose. Nothing in this app imports it; it + # arrives through magic's Pick facade, so magic's own constraint is the only + # one that should decide the version. Pinning it here as a second opinion + # bought nothing (published magic 0.0.9 already caps it at <12.0.0-0) and + # cost real breakage: magic's master moved to ^12.2.0 ahead of a release, and + # a pin of ^11.0.2 does not intersect that, so version solving failed outright + # against local sibling checkouts and bin/check could not run at all. Leaving + # it out means the resolved version follows whichever magic is in play, and + # the day magic publishes its file_picker 12 port nothing here needs editing. dev_dependencies: flutter_test: From 257db66a9ec5514d45df7ca22a031281a4d36006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 7 Sep 2026 02:48:49 +0300 Subject: [PATCH 08/10] Merge rewrites by path, so two generators cannot cancel each other _buildPlan spread the identity rewrites and the Dart-import scan into one list without merging by path, and the loop re-reads each path from disk. So a file claimed by both generators produced two planned writes from the same original source, and _apply performed them in order: the second discarded the first while the report counted the file as changed twice, with plausible line counts. lib/main.dart is the only path both can claim. It carries the MagicApplication title AND is scanned for a `package:` self-import. This repository never reaches the collision because its own main.dart imports relatively, which is exactly why the fix has to be structural rather than left to a fixture that mirrors this tree: a fork that self-imports loses its display name silently. The test introduces the self-import to reach the case, and fails on the old code with "the display-name rewrite was discarded by the import rewrite". The published job now reads the expected version floor out of pubspec.yaml instead of carrying a literal. A hard-coded alpha.26 would turn the job red on the next routine bump, in a file the bumping author has no reason to open, and name a version nobody typed. The comparison is a floor rather than an equality so a legitimately newer release still passes, verified against alpha.24 (rejected), alpha.26, alpha.27 and 0.0.2. --- .github/workflows/ci.yml | 24 ++++++++--- lib/app/commands/app_rename_command.dart | 15 +++++++ .../app/commands/app_rename_command_test.dart | 40 +++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc27dd1..9caa88c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,13 +162,25 @@ jobs: run: flutter analyze # `flutter pub get` exits 0 on a downgrade as long as SOME version in the # constraint range resolves; it does not care which one. Reading the lock's - # own hosted version is the only way to tell alpha.26 (intended) apart from - # alpha.24 (the silent walk-back this job exists to catch). - - name: Assert magic_starter resolved to the intended release + # own hosted version is the only way to tell the intended release apart + # from a silent walk-back to an older prerelease, which is what this repo + # shipped for several releases: a `magic_notifications` constraint stopped + # intersecting the starter's own requirement and pub quietly chose + # alpha.24 instead of failing. + # + # The floor is READ from pubspec.yaml rather than written here. A literal + # would turn this job red on the next routine bump, in a file whose author + # has no reason to look, and the failure would name a version nobody typed. + - name: Assert magic_starter did not resolve below its constraint floor run: | + floor=$(grep -E '^[[:space:]]+magic_starter:' pubspec.yaml | sed -E 's/.*\^//') resolved=$(awk '/^ magic_starter:/{f=1; next} f && /^ version:/{print $2; exit} f && /^ [a-zA-Z]/{exit}' pubspec.lock | tr -d '"') - echo "resolved magic_starter version: ${resolved:-}" - if [ "$resolved" != "0.0.1-alpha.26" ]; then - echo "expected magic_starter 0.0.1-alpha.26, got '${resolved:-}'. A hosted resolve that lands on an older prerelease (alpha.24, measured before Step 1) means a dependency constraint stopped intersecting alpha.26's own requirements while pub get kept exiting 0." >&2 + echo "constraint floor: ${floor:-} | resolved: ${resolved:-}" + if [ -z "$floor" ] || [ -z "$resolved" ]; then + echo "could not read the floor from pubspec.yaml or the version from pubspec.lock; the assertion below would pass vacuously" >&2 + exit 1 + fi + if [ "$(printf '%s\n%s\n' "$floor" "$resolved" | sort -V | head -1)" != "$floor" ]; then + echo "magic_starter resolved to '$resolved', which is BELOW the '$floor' floor in pubspec.yaml. Some constraint stopped intersecting what that release requires, and pub picked an older one rather than failing." >&2 exit 1 fi diff --git a/lib/app/commands/app_rename_command.dart b/lib/app/commands/app_rename_command.dart index d3b1eb4..b9488e6 100644 --- a/lib/app/commands/app_rename_command.dart +++ b/lib/app/commands/app_rename_command.dart @@ -303,10 +303,25 @@ class AppRenameCommand extends ArtisanCommand { final unchanged = []; final absent = []; + // Merge by path before planning, because the two generators overlap and the + // loop below re-reads each path from DISK. `lib/main.dart` carries an + // identity rule (the MagicApplication title) and is also scanned for a + // `package:` self-import, so in a fork that has one it appears twice; the + // second pass would start from the original source again and `_apply` would + // write it last, discarding the title rewrite while the report still listed + // the file as changed. This repository cannot reach that case, since its + // own `main.dart` imports relatively, which is exactly why it needs to be + // structural rather than left to a test fixture that mirrors this tree. + final merged = >{}; for (final rewrite in [ ..._identityRewrites(from, to), ..._dartImportRewrites(from, to), ]) { + merged.putIfAbsent(rewrite.path, () => <_Rule>[]).addAll(rewrite.rules); + } + + for (final entry in merged.entries) { + final rewrite = _FileRewrite(entry.key, entry.value); final source = _read(rewrite.path); if (source == null) { absent.add(rewrite.path); diff --git a/test/app/commands/app_rename_command_test.dart b/test/app/commands/app_rename_command_test.dart index 2b22ccf..4caba10 100644 --- a/test/app/commands/app_rename_command_test.dart +++ b/test/app/commands/app_rename_command_test.dart @@ -363,6 +363,46 @@ void main() { ); }); + test('a file matched by two generators keeps both rewrites', () async { + // lib/main.dart is the one path the identity rules and the Dart-import + // scan can both claim: it carries the MagicApplication title AND, in a + // fork that self-imports, a `package:/` line. THIS repo escapes the + // collision only because its own main.dart imports relatively, so the + // fixture has to introduce the self-import to reach the case a fork hits. + // + // Before the plan merged rewrites by path, the second entry re-read the + // file from disk and `_apply` wrote it last, so the import rewrite landed + // and the title rewrite was discarded while the report still counted the + // file as changed. + final root = fixture(); + final main = File('${root.path}/lib/main.dart'); + main.writeAsStringSync( + "import 'package:magic_example/config/app.dart';\n" + "${main.readAsStringSync()}", + ); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + expect(result.code, 0); + + final rewritten = main.readAsStringSync(); + expect( + rewritten, + contains("import 'package:acme_app/config/app.dart';"), + reason: 'the package import was not rewritten', + ); + expect( + rewritten, + contains("MagicApplication(title: 'Acme App'"), + reason: 'the display-name rewrite was discarded by the import rewrite', + ); + expect(rewritten, isNot(contains('magic_example'))); + }); + test('rewrites the macOS RunnerTests host application', () async { // TEST_HOST is the only functional line in the macOS project file. Left // stale it points at a bundle the renamed fork no longer produces, and From 4c3269cbbb5ed3e8517cd7648e05520dc72912aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sun, 13 Sep 2026 15:42:20 +0300 Subject: [PATCH 09/10] chore(deps): pin every fluttersdk dependency at the release that exists The nine constraints were written against what was published when this branch opened, and five releases have gone out since: magic 0.0.11, magic_deeplink 0.1.0, magic_notifications 0.3.1, magic_starter 0.0.27 and fluttersdk_artisan 0.0.15. Two of them were not lagging but EXCLUDING. A caret raises the minor whenever the major is zero, so magic_deeplink ^0.0.3 ends at 0.1.0 and magic_notifications ^0.2.0 ends at 0.3.0: the two releases that carry the cold-start push fix this app is the demonstration of could not be resolved here at all. Pub does not report that as a conflict, it walks another package backwards and exits zero, which is the failure this branch exists to close. The macOS plugin registrant moves with them: magic 0.0.10 took file_picker to 12, which splits the plugin into federated platform packages, so the generated import is file_picker_darwin rather than file_picker. It is a tracked generated file and has to match the graph the lock describes. Resolved hosted with no overrides, which is what the published CI job asserts. dart analyze clean, 27 tests green. --- macos/Flutter/GeneratedPluginRegistrant.swift | 2 +- pubspec.lock | 100 ++++++++++++++---- pubspec.yaml | 10 +- 3 files changed, 84 insertions(+), 28 deletions(-) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1d7441a..744ed3d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,7 @@ import Foundation import app_links import desktop_webview_window -import file_picker +import file_picker_darwin import file_selector_macos import flutter_secure_storage_darwin import flutter_timezone diff --git a/pubspec.lock b/pubspec.lock index 2c05652..60d84cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "014c74ab48d452c3252465682375a7fe6ddf56abb908ec361f207a3c4ffb2444" + url: "https://pub.dev" + source: hosted + version: "1.1.1" app_links: dependency: transitive description: @@ -217,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -229,10 +245,42 @@ packages: dependency: transitive description: name: file_picker - sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" + sha256: "9be6aac79508dbcf8dac80a4fb20f27ce6f910837ca27e3670b9295995cc0110" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "59fa5394cfa5b6dc8bf491630cb17077b77b844adbb840df371e7be5355d6cf6" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "0355558fd9af6da499d18e333d6b3beb44b0bf19e00933fccd15d18eb8d0e9ca" url: "https://pub.dev" source: hosted - version: "11.0.3" + version: "3.4.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227" + url: "https://pub.dev" + source: hosted + version: "3.1.0" file_selector_linux: dependency: transitive description: @@ -351,10 +399,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.2" flutter_svg: dependency: transitive description: @@ -401,10 +449,10 @@ packages: dependency: "direct main" description: name: fluttersdk_artisan - sha256: "15f77c2e57663c75358d4ae6e6b72c74b53a8340478522d75449141bbcef3385" + sha256: "12edf7ed81708340b52e854dd670e89db3f7bdc1996b307a9e7ae51793e77625" url: "https://pub.dev" source: hosted - version: "0.0.14" + version: "0.0.15" fluttersdk_dusk: dependency: "direct main" description: @@ -425,10 +473,10 @@ packages: dependency: transitive description: name: fluttersdk_wind - sha256: "7374b032db392aa092e0e05466ec3f510b32f84d0179de66691d3d022a65d65f" + sha256: b436a41ab042be1cc578a69a6634e3f39cae2801505e6e2514d4e2ad9dd7f4fd url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.3" fluttersdk_wind_diagnostics_contracts: dependency: transitive description: @@ -713,18 +761,18 @@ packages: dependency: "direct main" description: name: magic - sha256: "6ed2efc9e09e3803c4dd180d80050eba7981325c3496e21ddb6620b25e11b3e0" + sha256: e018522fdadbe8b30d7fa1f9f061ae2082933a34f9915ccc78257c6a05711d84 url: "https://pub.dev" source: hosted - version: "0.0.9" + version: "0.0.11" magic_deeplink: dependency: "direct main" description: name: magic_deeplink - sha256: "6aa5ea94fdc926ee06958d27a3bcc8f632d17b55bfa56fdfc77e290f6728b6a0" + sha256: "4993fa9db2030a0e85a1f9381d81b179d3f1e0acd82d3c2c1f27cb5005b96549" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.1.0" magic_devtools: dependency: "direct main" description: @@ -737,10 +785,10 @@ packages: dependency: "direct main" description: name: magic_notifications - sha256: "9ec9464b88ff628881a26870a2b6a45fba901acf515be451230d0691f2b7f97c" + sha256: "507de0d7ccb03aa002cc5474276cafe8023bb7fcdc1812fdd48f50a779a831a1" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.3.1" magic_payments: dependency: transitive description: @@ -761,10 +809,10 @@ packages: dependency: "direct main" description: name: magic_starter - sha256: "86915e3dd2bec379e0ab69c829c14ebdd185b68f6bb9a714bf1d06b9d2acdef0" + sha256: b16c8b0308d46281934c9d9bf1c52e34132a9bb0ac8e6312cc3d340f62806f94 url: "https://pub.dev" source: hosted - version: "0.0.1-alpha.26" + version: "0.0.27" matcher: dependency: transitive description: @@ -985,18 +1033,18 @@ packages: dependency: transitive description: name: share_plus - sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" url: "https://pub.dev" source: hosted - version: "12.0.2" + version: "13.3.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.2.0" shared_preferences: dependency: transitive description: @@ -1286,10 +1334,10 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.4.0" window_to_front: dependency: transitive description: @@ -1298,6 +1346,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.4" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "62e6e6e115231d1d1d71c7b5883b4091ce3e0190e49548dfb0ff8cba8d91ad96" + url: "https://pub.dev" + source: hosted + version: "1.3.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4fe4ba7..78b30c6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,11 +35,11 @@ dependencies: # copied outside this workspace resolves without any sibling checkout. # In-workspace development overrides these to local path checkouts via the # gitignored pubspec_overrides.yaml; that file never ships in the fork. - magic: ^0.0.9 - magic_deeplink: ^0.0.3 - magic_notifications: ^0.2.0 + magic: ^0.0.11 + magic_deeplink: ^0.1.0 + magic_notifications: ^0.3.1 magic_social_auth: ^0.0.3 - magic_starter: ^0.0.1-alpha.26 + magic_starter: ^0.0.27 # Dev-tooling, imported by lib/main.dart under kDebugMode (release tree-shakes). magic_devtools: ^0.0.4 @@ -49,7 +49,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - fluttersdk_artisan: ^0.0.14 + fluttersdk_artisan: ^0.0.15 # No file_picker pin here on purpose. Nothing in this app imports it; it # arrives through magic's Pick facade, so magic's own constraint is the only From 653d31e4c99aba4739807d5d4a55157b6724fda1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sun, 13 Sep 2026 16:09:32 +0300 Subject: [PATCH 10/10] fix(rename,config): close four defects this branch introduces app:rename refused a target whose Kotlin package would sit INSIDE the current one only by accident: the guard compared the two paths for equality, and --org=com.fluttersdk.magic_example --name=app names a real identity whose move is the package into a child of itself. The apply step creates the destination first and then lists the source, so the rename landed on the directory it had just made and threw EINVAL after the identity rewrites were already on disk, leaving a half-renamed tree with git as the only undo. It is refused at validation time now, and the message names the two-run route that reaches the same identity without nesting. Equality is excluded, because a display-only rename leaves the Kotlin path untouched. appleId camel-cased only the package, so an org carrying an underscore (com.acme_inc, the ordinary Java encoding of acme-inc.com) produced com.acme_inc.acmeApp in both pbxproj files, and CFBundleIdentifier takes alphanumerics, hyphen and period alone. Every segment is camel-cased now. The report prints androidId and appleId beside the three inputs, because the two ids no longer match character for character and a dry run that hid them made the difference a surprise at code-signing time. lib/config/notifications.dart declared no service worker keys. The web driver drops an absent key from the config it hands the SDK, which then registers OneSignalSDKWorker.js at the root scope that a Flutter web build already owns, so whichever registration lands second wins and push silently never arrives. The two literals match what notifications:install writes into web/, and a fork cannot recover them by re-running that command because it refuses to overwrite an existing config file. database.enabled stays and gains a comment rather than being removed: nothing at runtime gates on it, but notifications:configure prints and rewrites it, and the package's own install stub declares it. Also fixes the floor gate in ci.yml, which compared with sort -V. GNU filevercmp orders an empty suffix below -alpha.1, so a walk-back from 0.0.27 to 0.0.27-alpha.1 sorted the floor first and PASSED the assertion that exists to catch exactly that, and an alpha floor against a stable resolution failed the opposite way. The comparison is semver now, verified against both directions. --- .github/workflows/ci.yml | 26 ++++++- lib/app/commands/app_rename_command.dart | 53 ++++++++++++-- lib/config/notifications.dart | 23 +++++++ .../app/commands/app_rename_command_test.dart | 69 +++++++++++++++++++ 4 files changed, 166 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9caa88c..27cb8e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,7 +180,31 @@ jobs: echo "could not read the floor from pubspec.yaml or the version from pubspec.lock; the assertion below would pass vacuously" >&2 exit 1 fi - if [ "$(printf '%s\n%s\n' "$floor" "$resolved" | sort -V | head -1)" != "$floor" ]; then + # Compared with semver rules rather than `sort -V`, which disagrees + # with them on exactly the versions this job exists to catch: GNU + # filevercmp orders an empty suffix BELOW `-alpha.1`, so a floor of + # `0.0.27` against a walk-back to `0.0.27-alpha.1` puts the floor + # first and the assertion passed (measured locally, both orderings). + # The same disagreement fires the other way on an alpha floor and a + # stable resolution, turning a genuine upgrade red. python3 is on + # every GitHub-hosted runner. + if ! python3 -c ' + import sys + + def key(version): + core, _, pre = version.partition("+")[0].partition("-") + numbers = tuple(int(part) for part in core.split(".")) + if not pre: + # Semver 11.3: no prerelease outranks one carrying it. + return (numbers, 1, ()) + identifiers = tuple( + (0, int(p), "") if p.isdigit() else (1, 0, p) + for p in pre.split(".") + ) + return (numbers, 0, identifiers) + + sys.exit(0 if key(sys.argv[2]) >= key(sys.argv[1]) else 1) + ' "$floor" "$resolved"; then echo "magic_starter resolved to '$resolved', which is BELOW the '$floor' floor in pubspec.yaml. Some constraint stopped intersecting what that release requires, and pub picked an older one rather than failing." >&2 exit 1 fi diff --git a/lib/app/commands/app_rename_command.dart b/lib/app/commands/app_rename_command.dart index b9488e6..0ae82ce 100644 --- a/lib/app/commands/app_rename_command.dart +++ b/lib/app/commands/app_rename_command.dart @@ -141,6 +141,38 @@ class AppRenameCommand extends ArtisanCommand { display: display ?? current.display, ); + // 2b. Refuse a target whose Kotlin directory would sit INSIDE the current + // one. `--org=com.fluttersdk.magic_example --name=app` passes both + // patterns above and names a real identity, but the move it implies is + // `.../magic_example` into `.../magic_example/app`: the apply step + // creates the destination first, `listSync` then returns it, and + // renaming a directory into itself is EINVAL. That throw would land + // after the identity rewrites are already on disk, leaving a half + // renamed tree with git as the only undo, which is exactly what the + // dirty-worktree guard below exists to keep survivable. + // + // Refused here rather than repaired in `_planKotlinMove`, because the + // whole point of planning before writing is that a refusal costs + // nothing. The other direction, a destination that is an ANCESTOR of + // the source, is safe and stays allowed: `createSync` is idempotent, + // `listSync` sees only real children, and the emptied leaf is pruned. + // Equality is excluded rather than caught here: an unchanged Kotlin + // path is what a `--display`-only rename produces, and + // `_planKotlinMove` already answers that with no move at all. + if (target.kotlinPath != current.kotlinPath && + '${target.kotlinPath}/'.startsWith('${current.kotlinPath}/')) { + ctx.output.error( + 'app:rename refused: the Kotlin package directory it would move into ' + '(android/app/src/main/kotlin/${target.kotlinPath}) sits inside the ' + 'one it moves from (${current.kotlinPath}), and a directory cannot be ' + 'renamed into itself.\n' + 'Reach the same identity in two runs, committing between them: ' + 'app:rename --name=${target.package}, then ' + 'app:rename --org=${target.org}. Neither run nests.', + ); + return 1; + } + // 3. Refuse a dirty worktree, but only when actually writing. A dry run has // to stay usable in the middle of unrelated work. if (!dryRun) { @@ -634,6 +666,12 @@ class AppRenameCommand extends ArtisanCommand { out.writeln(' package ${from.package} -> ${to.package}'); out.writeln(' org ${from.org} -> ${to.org}'); out.writeln(' display ${from.display} -> ${to.display}'); + // The two derived ids, because they are not the concatenation a reader + // predicts: the Apple one camel cases every segment (see [appleId]) and + // the Android one keeps the underscores, so a dry run that printed only + // the three inputs above hid the one value code signing rejects. + out.writeln(' androidId ${from.androidId} -> ${to.androidId}'); + out.writeln(' appleId ${from.appleId} -> ${to.appleId}'); out.writeln(''); out.writeln('changed (${plan.changed.length}):'); @@ -806,14 +844,21 @@ class _Identity { String get androidId => '$org.$package'; /// Apple bundle identifier. Apple's tooling rejects underscores in a bundle - /// id, so the package name is camel cased here and only here. - String get appleId => '$org.$_camelPackage'; + /// id, so EVERY segment is camel cased here and only here, the org included: + /// `com.acme_inc` is the ordinary Java and Kotlin encoding of the domain + /// `acme-inc.com`, so refusing it in [_orgPattern] would decline a legitimate + /// org, while camel casing only the package would leave `com.acme_inc.acmeApp` + /// in `CFBundleIdentifier`, which allows alphanumerics, hyphen and period + /// alone. The result no longer matches [androidId] character for character, + /// which is why [_report] prints both. + String get appleId => + '${org.split('.').map(_camel).join('.')}.${_camel(package)}'; /// Directory path under `android/app/src/main/kotlin/`. String get kotlinPath => '${org.split('.').join('/')}/$package'; - String get _camelPackage { - final parts = package.split('_'); + static String _camel(String segment) { + final parts = segment.split('_'); return parts.first + parts .skip(1) diff --git a/lib/config/notifications.dart b/lib/config/notifications.dart index 7339ccd..cfa3f30 100644 --- a/lib/config/notifications.dart +++ b/lib/config/notifications.dart @@ -25,6 +25,24 @@ Map get notificationsConfig => { 'notify_button_enabled': false, + // Where the OneSignal worker is served from and the scope it claims, + // matching the two constants `notifications:install` writes into + // `web/` (`_serviceWorkerPath`, `_serviceWorkerScope`). Literals rather + // than `env` for the same reason as the flag above: this is a build + // layout fact, not a per-environment one. + // + // Load-bearing on the web, and silently so. `OneSignalWebDriver` omits + // an absent key from the config it hands the SDK, which then registers + // `OneSignalSDKWorker.js` at the ROOT scope, and a Flutter web build + // already owns that scope with `flutter_service_worker.js`: whichever + // registration lands second wins it, and when Flutter's does, push + // never arrives on a device the reconciler still reports as converged. + // A fork cannot recover these by re-running the installer either, since + // `notifications:install` refuses to overwrite a config file that + // exists while still writing the worker beside it. + 'service_worker_path': 'OneSignalSDKWorker.js', + 'service_worker_scope': '/onesignal/', + // ---------------------------------------------------------------------- // The permission posture: ask once where a gesture already justifies it, // never spend the one-shot browser prompt on an unprompted page load. @@ -61,6 +79,11 @@ Map get notificationsConfig => { 'fallback_to_settings': true, }, 'database': { + // Declared for parity with the package's own install stub and read by + // `notifications:configure`, which prints it and rewrites it in place. + // No runtime code gates on it: `DatabaseChannel.isAvailable` answers + // true unconditionally, so switching this off does not stop the poller. + // Use `Notify.stopPolling()` for that. 'enabled': true, 'polling_interval': 30, // seconds }, diff --git a/test/app/commands/app_rename_command_test.dart b/test/app/commands/app_rename_command_test.dart index 4caba10..2502fed 100644 --- a/test/app/commands/app_rename_command_test.dart +++ b/test/app/commands/app_rename_command_test.dart @@ -425,6 +425,38 @@ void main() { }); group('app:rename refusals', () { + test('camel cases an underscored org into the Apple bundle id', () async { + // `com.acme_inc` is the ordinary Java and Kotlin encoding of the domain + // `acme-inc.com`, so it is accepted rather than refused. Apple is the + // one platform that cannot take it: CFBundleIdentifier allows + // alphanumerics, hyphen and period alone, so an underscore surviving + // into the pbxproj fails code signing. Android keeps it, because the + // underscore is significant to plugin discovery. + final root = fixture(); + + await _run( + root, + name: 'acme_app', + org: 'com.acme_inc', + display: 'Acme App', + ); + + String read(String path) => + File('${root.path}/$path').readAsStringSync(); + expect( + read('ios/Runner.xcodeproj/project.pbxproj'), + contains('PRODUCT_BUNDLE_IDENTIFIER = com.acmeInc.acmeApp;'), + ); + expect( + read('macos/Runner/Configs/AppInfo.xcconfig'), + contains('PRODUCT_BUNDLE_IDENTIFIER = com.acmeInc.acmeApp'), + ); + expect( + read('android/app/build.gradle.kts'), + contains('com.acme_inc.acme_app'), + ); + }); + test('rejects a --name that is not a Dart package identifier', () async { for (final invalid in const ['Acme App', '../evil', 'com.acme', '9app']) { final root = fixture(); @@ -443,6 +475,43 @@ void main() { } }); + test('rejects a target whose Kotlin package nests inside the current one', + () async { + // `com.fluttersdk.magic_example` plus `app` is a valid identity by every + // pattern, and the move it implies is the fixture's own package into a + // child of itself. The apply step creates the destination first and then + // lists the source, so the rename lands on the directory it just made + // and throws EINVAL, after the identity files are already written. The + // refusal has to come before any of that, which is what the snapshot + // assertion pins. + final root = fixture(); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'app', + org: 'com.fluttersdk.magic_example', + display: 'App', + ); + + expect(result.code, 1); + expect(result.output, contains('sits inside the one it moves from')); + expect(result.output, contains('app:rename --name=app')); + expect(_snapshot(root), before); + }); + + test('allows a rename that only moves the display name', () async { + // The nesting guard reads two Kotlin paths, and a display-only rename + // leaves them identical. Refusing on equality would decline the one + // rename that moves no directory at all. + final root = fixture(); + + final result = await _run(root, display: 'Renamed App'); + + expect(result.code, 0); + expect(result.output, isNot(contains('sits inside'))); + }); + test('rejects an --org that is not reverse DNS', () async { for (final invalid in const ['acme', '../../etc', 'com..acme', 'Com.A']) { final root = fixture();