22 Commits

Author SHA1 Message Date
gasaichandesu e98bcf407c fix(sync): ignore AddOns subfolders to make sure we do not overwrite user's addons 2026-08-07 05:15:49 +04:00
sindoring f395d2515d fix 2026-07-28 21:28:53 +04:00
sindoring 3b01afeeb2 фиксы 2026-07-28 21:25:06 +04:00
sindoring b7e6a4b393 fix 2026-07-28 21:12:21 +04:00
sindoring 33659f6c05 fix 2026-07-28 20:56:19 +04:00
sindoring bd1e464b5a деплой 2026-07-28 20:51:54 +04:00
sindoring 3d43420676 fix 2026-07-28 20:48:31 +04:00
sindoring c4576c84bb Merge pull request 'chore: New UI implementation' (#1) from feat/ui into master
Reviewed-on: #1
2026-07-28 19:44:14 +03:00
sindoring 0b6f7fc647 правки по лаунчеру 2026-07-28 20:43:53 +04:00
sindoring 155922a3f1 Merge branch 'master' into feat/ui 2026-07-28 18:18:29 +04:00
sindoring 3134ecad0f refresh manifest before client sync 2026-07-19 16:10:35 +04:00
gasaichan bb4334f68d WIP: feat(ui): Migrate to new UI implementation 2026-06-22 06:47:16 +04:00
gasaichan 873aa8d7b5 chore: Bring back FVM configuration 2026-06-22 06:46:47 +04:00
gasaichan 602d294da4 feature(updater): Implement auto-updating system using AppCast protocol 2026-06-22 02:30:50 +04:00
gasaichan c23b4bd0da chore: Remove TODO.md 2026-06-21 18:34:13 +04:00
gasaichan 319440f171 build: Add MacOS build target 2026-06-21 03:54:24 +04:00
gasaichandesu 07b9e44350 refactor(auth): Migrate session storage to flutter_secure_storage 2026-06-19 22:58:27 +04:00
gasaichandesu 8f6ed5504b chore: Add lefthook configuration 2026-06-19 22:49:35 +04:00
gasaichandesu 05d1bb2428 deps: Add flutter_secure_storage 2026-06-19 17:52:12 +04:00
gasaichandesu 0363e915ee docs: Add basic AI guidelines and documentation 2026-06-18 23:51:07 +04:00
gasaichandesu 2bc54f1cd7 refactor(ui): Minor refactoring, extract repeating title widget 2026-06-18 23:48:19 +04:00
gasaichandesu 51f6a91bbf build: Upgrade to Flutter 3.44.2 2026-06-18 23:43:36 +04:00
190 changed files with 19487 additions and 2044 deletions
+7
View File
@@ -0,0 +1,7 @@
AWS_ACCESS_KEY_ID=replace_with_access_key_id
AWS_SECRET_ACCESS_KEY=replace_with_secret_access_key
AWS_DEFAULT_REGION=ru-central1
AWS_BUCKET=warcraft-client
AWS_ENDPOINT=https://storage.yandexcloud.net
AWS_USE_PATH_STYLE_ENDPOINT=true
LAUNCHER_AUTH_KEY=replace_with_launcher_auth_key
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "stable"
}
+9 -1
View File
@@ -45,4 +45,12 @@ app.*.map.json
/android/app/release
# FVM Version Cache
.fvm/
.fvm/
# Local launcher update signing material
.moonwell_signing/
# Local production deployment credentials
.env
.env.*
!.env.example
+5 -11
View File
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "20f82749394e68bcfbbeee96bad384abaae09c13"
revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18"
channel: "stable"
project_type: app
@@ -13,17 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
- platform: linux
create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
- platform: macos
create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
- platform: windows
create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
# User provided section
+3 -1
View File
@@ -1 +1,3 @@
{}
{
"dart.flutterSdkPath": ".fvm/versions/stable"
}
+111
View File
@@ -0,0 +1,111 @@
# AGENTS.md
This file is the working guide for people and agents changing MoonWell
Launcher. Keep it current when architecture, commands, or repository rules
change.
## Project Shape
MoonWell Launcher is a Flutter desktop app for a private World of Warcraft:
Wrath of the Lich King server. It logs players in, downloads and synchronizes
client files, displays launcher news, and starts `Wow.exe`.
Important areas:
- `lib/main.dart` initializes Flutter, the desktop window, dependency
injection, and `MoonWellApp`.
- `lib/app` contains UI screens, BLoC state, widgets, and theme code.
- `lib/app/widgets` contains shared app-level UI such as the custom desktop
title bar.
- `lib/app/design_system` contains presentation-only catalog components.
- `widgetbook/` is the standalone component-catalog workspace. Stories and
fixture data stay in that workspace and import components from the launcher.
- `lib/features/launcher/domain` contains launcher entities such as sessions,
manifests, news items, sync status, and exceptions.
- `lib/features/launcher/application` contains use cases for session restore
and client synchronization.
- `lib/features/launcher/data` contains API, installation, and log services.
- `lib/features/preferences` stores selected install directory and launcher
session data. Launcher sessions must use secure storage; non-sensitive
preferences may use `shared_preferences`.
- `lib/service_container.dart` and `lib/service_container.config.dart` provide
`get_it`/`injectable` dependency wiring.
- `docs/launcher_web_api_spec.md` documents the current launcher Web API and
sync behavior.
## Flutter SDK Policy
MoonWell Launcher tracks the latest stable Flutter SDK through FVM. Run all
Dart and Flutter commands through `fvm` and keep `.fvmrc` on the stable channel.
## Common Commands
```powershell
fvm flutter pub get
fvm dart run build_runner build --delete-conflicting-outputs
fvm dart format --set-exit-if-changed .
fvm flutter analyze
fvm flutter test
fvm flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
fvm flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
cd widgetbook
fvm flutter pub get
fvm dart run build_runner build
fvm flutter run -d windows
```
See `docs/linting.md` for the required format, analyze, test, and Lefthook
pre-commit workflow.
Follow `docs/widgetbook.md` for all Widgetbook use-case generation.
## Configuration
The launcher API base URL is compile-time configuration:
```powershell
--dart-define=MOONWELL_API_BASE_URL=https://host
```
The optional Windows self-update feed is also compile-time configuration:
```powershell
--dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
```
Production AppCast feeds must be public HTTPS URLs. Omit the setting to disable
self-update in local builds.
Do not commit private credentials, production secrets, temporary bearer tokens,
or local-only API URLs.
## Architecture Notes
- All backend calls should go through `LauncherApiClient`.
- Launcher bearer tokens must be persisted through `flutter_secure_storage`.
Do not store sessions or other secrets in `shared_preferences`.
- File sync behavior should stay in `ClientSyncUseCase` and
`GameInstallationService`.
- Launcher self-update behavior should stay behind `LauncherUpdateService`.
Windows updates use WinSparkle/AppCast and the signed Inno Setup artifact;
they are separate from game-client synchronization.
- The server manifest is the source of truth for client files.
- Downloads must write to `*.moonwell.part`, verify size and SHA-256, then
replace the destination file only after successful verification.
- Local scans should ignore volatile client directories and launcher metadata:
`Cache`, `Errors`, `Logs`, `Screenshots`, `WTF`, and `.moonwell_launcher`.
- Path normalization and traversal checks are safety-critical. Do not bypass
`GameInstallationService.resolveClientPath` for manifest-relative paths.
- `lib/service_container.config.dart` is generated by injectable. Do not
hand-edit it unless you are intentionally repairing generated output; prefer
regenerating it with build_runner.
## Repository Rules
- Preserve unrelated work in a dirty tree. This repository currently may have
user edits in dependency files.
- Keep changes scoped to the requested behavior.
- Always keep `README.md` and files under `docs/` up-to-date with any
architecture, setup, API, sync, build, or operational change.
- Keep documentation links current when files move.
- Keep catalog widgets presentation-only. Do not call BLoCs, APIs, storage,
file pickers, dependency injection, or native window plugins from them.
+151 -10
View File
@@ -1,16 +1,157 @@
# moonwell_launcher
# MoonWell Launcher
A new Flutter project.
MoonWell Launcher is the desktop launcher for MoonWell, a private World of
Warcraft: Wrath of the Lich King server focused on a player-friendly experience
without excessive grind and with stronger social play.
## Getting Started
The Windows executable requests administrator privileges on startup so it can
synchronize game clients installed under protected directories such as
`Program Files`.
This project is a starting point for a Flutter application.
Launcher version labels are read from the packaged application metadata. Change
only the `version: X.Y.Z+N` value in `pubspec.yaml`; login, launcher status,
Windows metadata, installer packaging, and AppCast are synchronized from it.
A few resources to get you started if this is your first Flutter project:
The launcher is responsible for:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
- logging in to a player's MoonWell account
- registering new MoonWell game accounts
- downloading and synchronizing client files
- displaying launcher news
- displaying the live status of the first configured realm
- launching the game client
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
## Stack
- Flutter desktop
- Dart
- `flutter_bloc` for launcher UI state
- `get_it` and `injectable` for dependency injection
- `dio` for Web API and file download requests
- `flutter_secure_storage` for launcher sessions
- `shared_preferences` for non-sensitive launcher preferences
## Prerequisites
Install FVM. The repository `.fvmrc` tracks the latest stable Flutter channel,
and all Dart and Flutter commands are run through FVM.
```powershell
fvm flutter pub get
```
Verify the configured SDK with `flutter --version`.
## Configuration
Set the launcher Web API base URL at compile time:
```powershell
--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online
```
Set the public AppCast feed URL to enable Windows launcher self-updates:
```powershell
--dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The updater is disabled when `MOONWELL_APPCAST_URL` is omitted. Production
feeds must use HTTPS.
Example run command:
```powershell
flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://moon-well.online --dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
## Development
Run the component catalog from its standalone workspace:
```powershell
cd widgetbook
dart run build_runner build
flutter run -d windows
```
See `docs/widgetbook.md` for the required use-case naming, knobs, callback,
asset, and coverage conventions.
Regenerate dependency injection after changing injectable services:
```powershell
dart run build_runner build --delete-conflicting-outputs
```
Run static analysis:
```powershell
flutter analyze
```
See `docs/linting.md` for the full format, analyze, test, and Lefthook
pre-commit workflow.
Build the Windows launcher:
```powershell
flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://moon-well.online --dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The Inno Setup installer script lives at `installer/moonwell_launcher.iss`.
See `docs/launcher_self_update.md` for the AppCast format, signing keys, and
manual release procedure.
Run a safe local production-release rehearsal:
```powershell
.\tool\deploy_launcher.ps1 `
-DryRun `
-ExpectedVersion 1.0.2 `
-ReleaseNotes "Исправления и улучшения MoonWell Launcher."
```
Remove `-DryRun` only after reviewing the generated installer and AppCast.
See `docs/production_deploy.md` for required environment variables, production
defaults, safety checks, and failure recovery.
Local deploy credentials are read from the ignored root `.env`; use
`.env.example` as the safe template. Process and CI environment variables
override values from the file.
## Architecture
The UI lives in `lib/app`. Presentation-only design-system components live in
`lib/app/design_system` and are catalogued by the standalone `widgetbook/`
workspace before they are integrated into production screens.
Launcher domain models, sync use cases, API clients, installation services, and
preferences live under `lib/features`.
The launcher sync flow is manifest-based:
1. authenticate against the MoonWell Web API
2. fetch the client manifest and automatically synchronize a saved client
installation
3. refresh the manifest every five minutes while the launcher is open and
automatically synchronize when the server build changes
4. scan the selected installation directory
5. compare local files to the manifest
6. remove stale files outside ignored directories
7. download missing or changed files to temporary part files
8. verify size and SHA-256 before replacing client files
9. launch `Wow.exe` from the selected client directory
See `docs/launcher_web_api_spec.md` for the API and sync contract.
## Documentation
- `AGENTS.md`: architecture, commands, and repository rules for contributors
- `TODO.md`: remaining verification and maintenance tasks
- `docs/linting.md`: format, analyze, test, and Lefthook workflow
- `docs/project_review.md`: project review with strengths, weaknesses, and
upgrade feasibility notes
- `docs/launcher_web_api_spec.md`: current launcher Web API and sync behavior
- `docs/launcher_self_update.md`: Windows self-update and release operations
- `docs/production_deploy.md`: automated production launcher deployment
- `docs/widgetbook.md`: Widgetbook use-case generation and catalog conventions
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>MoonWell Launcher</title>
<description>MoonWell Launcher updates</description>
<language>ru</language>
<item>
<title>MoonWell Launcher 1.0.2</title>
<description>Исправлено отображение версии и добавлен запуск с правами администратора.</description>
<pubDate>Tue, 28 Jul 2026 21:23:41 +04:00</pubDate>
<enclosure url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe" sparkle:version="1.0.2+4" sparkle:shortVersionString="1.0.2" sparkle:os="windows" sparkle:dsaSignature="MD0CHQDFH5GJKhykmXEZJTInFPrsoIkDc9tKroeR2XvJAhxrRjugtgetJlDiAhlyOtvgIYVRmAIb2sVqLelj" length="19994970" type="application/octet-stream" />
</item>
</channel>
</rss>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+161
View File
@@ -0,0 +1,161 @@
# Launcher Self-Update
MoonWell Launcher uses `auto_updater` 1.0.0, backed by WinSparkle 0.8.1, to
update installed Windows launchers from an AppCast feed. This is independent of
the manifest-based World of Warcraft client synchronization.
The launcher performs one background update check after its window is visible.
WinSparkle shows its native prompt only when a newer version is available.
Missing configuration, network failures, and malformed feeds do not block the
launcher. Periodic WinSparkle checks are disabled.
`MoonWell.exe` uses the Windows `requireAdministrator` execution level. Users
therefore receive a UAC prompt whenever the launcher starts. This is required
to synchronize game files installed in protected directories. The per-user
Inno Setup installer remains configured with `PrivilegesRequired=lowest`.
## Build Configuration
Pass the public feed URL at build time:
```powershell
flutter build windows `
--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online `
--dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The feed URL must be an absolute HTTPS URL. Omit
`MOONWELL_APPCAST_URL` to disable self-update, including for ordinary local
development.
## Signing Keys
WinSparkle verifies installer artifacts with a DSA signature. The public key is
committed at `windows/runner/resources/dsa_pub.pem` and embedded as the
`DSAPub` resource. The corresponding private key is local signing material at
`.moonwell_signing/dsa_priv.pem`; it is ignored by Git and must be backed up to
the project secret store before publishing the first update.
Never commit or upload the private key. Losing it prevents existing
installations from accepting future updates. Rotating it requires shipping an
ordinary installer containing the replacement public key before publishing
artifacts signed by the replacement private key.
The AppCast signature is not Authenticode. Publicly trusted Authenticode
certificates and managed signing services are paid products, so MoonWell does
not currently Authenticode-sign the installer. Windows can therefore display
an unknown-publisher or SmartScreen warning. A free self-signed Authenticode
certificate would not establish trust on player machines.
## Version and AppCast Contract
Increase the `X.Y.Z` version in `pubspec.yaml` for every launcher release. A
build-number-only change such as `1.0.0+2` is not sufficient for a public
release. Flutter writes the complete `X.Y.Z+N` value into the Windows
`ProductVersion`; `sparkle:version` must match that complete value exactly.
Use `sparkle:shortVersionString` for the human-readable `X.Y.Z` value. Pass
`X.Y.Z` without the build suffix to Inno Setup:
```powershell
ISCC.exe /DMyAppVersion=1.1.0 installer\moonwell_launcher.iss
```
The resulting artifact is
`build/installer/moonwell_launcher_1.1.0_windows_setup.exe`.
Use a public AppCast with absolute URLs:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>MoonWell Launcher</title>
<description>MoonWell Launcher updates</description>
<language>ru</language>
<item>
<title>MoonWell Launcher 1.1.0</title>
<pubDate>Sun, 21 Jun 2026 12:00:00 +0400</pubDate>
<enclosure
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
sparkle:version="1.1.0+3"
sparkle:shortVersionString="1.1.0"
sparkle:os="windows"
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
length="ACTUAL_INSTALLER_SIZE_IN_BYTES"
type="application/octet-stream" />
</item>
</channel>
</rss>
```
The installer URL and release-notes URL must be absolute. The publication host
must serve the feed, notes, and installer without authentication or redirects
to an authenticated endpoint.
## Manual Release
Perform Windows signing on the Windows release machine because the package
invokes WinSparkle's Windows signing utility.
The recommended production workflow is the automated
[`tool/deploy_launcher.ps1`](../tool/deploy_launcher.ps1) script. See
[`production_deploy.md`](production_deploy.md) for prerequisites, environment
variables, dry-run usage, deployment, and failure recovery. The steps below are
the manual fallback and describe the same release order.
1. Update `pubspec.yaml` to the new `X.Y.Z+N` version.
2. Run `flutter pub get`, formatting, analysis, and all tests.
3. Build Windows with both production `--dart-define` values.
4. Compile the installer with `ISCC.exe /DMyAppVersion=X.Y.Z`.
5. Make the private key available outside the repository and sign the artifact:
```powershell
dart run auto_updater:sign_update `
build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe `
C:\secure-path\dsa_priv.pem
```
6. Copy the emitted `sparkle:dsaSignature` into the enclosure. The package's
Windows helper reports `length="0"`; replace it with the actual value from:
```powershell
(Get-Item build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe).Length
```
7. Upload the installer to the root of the `warcraft-client` bucket as
`moonwell_launcher_setup.exe`. The object must be publicly readable.
8. Download the hosted installer, verify its byte length, and test its DSA
signature before changing the feed.
9. Set `LAUNCHER_AUTH_KEY` in the release environment and publish
`appcast.xml` last:
```powershell
curl.exe `
-X POST "https://moon-well.online/api/service/update-appcast" `
-H "Auth: $env:LAUNCHER_AUTH_KEY" `
-H "Content-Type: application/xml" `
--data-binary "@appcast.xml"
```
10. Fetch `https://moon-well.online/appcast.xml` and verify the published
version, installer URL, byte length, and DSA signature.
Keep the preceding installer available for rollback and support. Rollback is a
new release with a product version higher than the faulty release; WinSparkle
does not install a lower version as an update.
## Release Verification
Test from an installed older version on a clean Windows x64 machine:
- offline or invalid feed: launcher remains usable and writes a log entry
- same or older feed version: no update prompt
- valid newer release: prompt, download, launcher exit, install, and relaunch
- modified installer or invalid DSA signature: WinSparkle rejects the update
- launcher session and selected game directory remain after installation
Watch the debug output for Flutter platform-channel thread warnings during the
test. `auto_updater` has an open Windows callback-thread issue; do not release
if it reproduces. Patch and pin `auto_updater_windows` so native callbacks are
marshalled onto Flutter's platform thread, then repeat the complete update test.
+48 -3
View File
@@ -29,9 +29,22 @@ Current integration mode is compile-time:
The launcher uses these endpoints from `openapi.json`:
1. `POST /api/launcher/login`
2. `GET /api/launcher/manifest`
3. `GET /api/launcher/download/{path}`
4. `GET /api/launcher/news`
2. `POST /api/launcher/register`
3. `GET /api/launcher/manifest`
4. `GET /api/launcher/download/{path}`
5. `GET /api/launcher/news`
6. `GET /api/launcher/realms`
7. `GET /api/launcher/account`
Registration sequence:
1. User opens the registration tab and provides `username`, `email`,
`password`, `password_confirmation`, optional `invite_code`, and accepts
`terms`.
2. Launcher sends the payload to `POST /api/launcher/register`.
3. After a successful `201` response, the form returns to the login tab and
displays the API success message.
4. Validation and invite errors returned by the API are shown in the form.
Authentication sequence:
@@ -41,6 +54,11 @@ Authentication sequence:
4. Successful login persists `LauncherSession` locally.
5. On next launcher start, saved session is reused to fetch manifest again.
6. `LauncherSession` and `ClientManifest` are passed into the home screen.
7. If an installation directory is saved, synchronization starts
automatically with the fetched manifest.
8. While the launcher is open, it refreshes the manifest every five minutes.
9. A server build hash that differs from the verified local build hash starts
synchronization automatically.
Logout sequence:
@@ -65,6 +83,28 @@ News sequence:
2. The response payload is read from the top-level `data` array.
3. Each news item provides `id`, `title`, `body`, optional `image_url`, and `created_at`.
4. News failures do not block patching or play flow; the launcher shows a local error state only inside the news panel.
5. Pressing a news item opens `<MOONWELL_API_BASE_URL>/news/{id}` in the
system browser.
Realm status sequence:
1. After the authenticated home screen opens, the launcher requests
`GET /api/launcher/realms`.
2. The first item in the top-level `data` array supplies the realm name,
online state, and game build shown in the sync panel.
3. Realm information is refreshed every minute while the launcher remains
open.
4. A failed refresh keeps the last known realm state and is retried on the next
interval.
Account sequence:
1. After the authenticated home screen opens, the launcher requests
`GET /api/launcher/account` with bearer authentication.
2. Only `username` is stored in launcher UI state and displayed in the account
menu.
3. The response `balance` field is intentionally ignored and never displayed.
4. Account metadata failures do not block patching or launching the game.
## Installation Directory Rules
@@ -193,6 +233,10 @@ When the user presses `Play`:
1. launcher resolves `<install-root>/Wow.exe`
2. launcher clears `<install-root>/Cache`
3. launcher starts `Wow.exe` with working directory set to installation root
and retains a process handle
4. while the process is alive, the launcher disables repeated game launches
and client synchronization
5. when the process exits, the launcher returns to the ready-to-play state
If `Wow.exe` is missing, launch fails with an error.
@@ -202,6 +246,7 @@ The launcher surfaces errors for:
- invalid API configuration
- authentication failure
- registration validation or server failure
- manifest load failure
- path traversal attempts
- checksum mismatch after download
+42
View File
@@ -0,0 +1,42 @@
# Linting And Test Flow
MoonWell Launcher tracks the latest stable Flutter SDK through FVM. Use `fvm`
for every Dart and Flutter command.
## Required Checks
Run these commands before committing:
```powershell
fvm dart format --set-exit-if-changed .
fvm flutter analyze
fvm flutter test
```
Use `fvm dart format .` to apply formatting fixes when the format check fails.
## Lefthook
This repository includes `lefthook.yml` with a `pre-commit` hook that runs the
same format, analyze, and test flow.
Install Lefthook once on your machine, then install the Git hook from the repo
root:
```powershell
lefthook install
```
Run the pre-commit flow manually with:
```powershell
lefthook run pre-commit
```
If `lefthook` is not available, install it with your preferred package manager
from the official Lefthook distribution, then rerun `lefthook install`.
## Windows Troubleshooting
If Flutter reports that building with plugins requires symlink support, enable
Windows Developer Mode and rerun the command.
+174
View File
@@ -0,0 +1,174 @@
# Production Launcher Deployment
`tool/deploy_launcher.ps1` automates a complete Windows launcher release:
1. reads `X.Y.Z+N` from `pubspec.yaml`
2. verifies that the private DSA key matches the public key embedded in the
launcher
3. runs dependency resolution, formatting checks, static analysis, and tests
4. builds the Windows release with the production API and AppCast URLs
5. compiles the Inno Setup installer
6. signs the installer and verifies the signature
7. generates `appcast.xml` with the real version, byte length, URL, and
signature
8. uploads the installer to Yandex Object Storage with public read access
9. downloads the published installer and compares its SHA-256
10. publishes AppCast through the service API and verifies the public feed
The installer is always uploaded before AppCast. Clients therefore never see a
release that has not passed the remote download and checksum verification.
## Prerequisites
The release machine needs:
- latest stable Flutter and Dart available on `PATH`
- Inno Setup 6, normally installed at
`C:\Program Files (x86)\Inno Setup 6\ISCC.exe`
- OpenSSL, normally supplied by Git for Windows at
`C:\Program Files\Git\usr\bin\openssl.exe`
- Python 3 with `boto3`
- `.moonwell_signing\dsa_priv.pem`
- the matching public key at `windows\runner\resources\dsa_pub.pem`
Install the Python dependency if required:
```powershell
python -m pip install boto3
```
The PowerShell entry point calls `tool/upload_launcher_release.py` as a regular
Python file. Keep this helper beside the deployment script; it performs the S3
upload and verifies the uploaded object's byte length.
Back up `.moonwell_signing\dsa_priv.pem` in the project secret store. Never
commit it, upload it to Object Storage, or send it through chat. Losing this key
prevents released launchers from accepting future updates.
## Local `.env`
For local releases, copy `.env.example` to `.env` and fill in the real values.
The repository ignores `.env` and every `.env.*` variant except the safe
`.env.example` template.
```powershell
Copy-Item .env.example .env
```
The deploy script loads `.env` automatically. Variables already present in the
current process or CI environment take precedence over the file. Use
`-EnvFile C:\secure\moonwell.env` to select a different local file.
The following values are supported:
| Variable | Required | Purpose |
| --- | --- | --- |
| `AWS_ACCESS_KEY_ID` | yes | Yandex Object Storage access key |
| `AWS_SECRET_ACCESS_KEY` | yes | Yandex Object Storage secret |
| `LAUNCHER_AUTH_KEY` | yes | `Auth` header for the AppCast service endpoint |
| `AWS_DEFAULT_REGION` | recommended | Defaults to `ru-central1` |
| `AWS_ENDPOINT` | optional | Defaults to `https://storage.yandexcloud.net` |
| `AWS_BUCKET` | optional | Defaults to `warcraft-client` |
| `AWS_USE_PATH_STYLE_ENDPOINT` | optional | Defaults to `true` |
Production CI should load these values from the team secret manager instead of
creating `.env`. Never commit the real file or put its contents into logs.
## Prepare a Release
Increase both parts of the version in `pubspec.yaml`:
```yaml
version: 1.0.2+3
```
`X.Y.Z` is the public launcher version. `N` is the Flutter build number.
Flutter places the complete `X.Y.Z+N` value in the Windows `ProductVersion`,
so the script writes that same complete value to `sparkle:version` and writes
`X.Y.Z` to `sparkle:shortVersionString`. Every production release must have an
`X.Y.Z` value greater than the release currently published in AppCast.
The UI reads `X.Y.Z` from the packaged application metadata through
`package_info_plus`. Version labels on the login and launcher screens therefore
update automatically after changing `pubspec.yaml`; do not add separate
hardcoded version strings.
Run a dry run first:
```powershell
.\tool\deploy_launcher.ps1 `
-DryRun `
-ExpectedVersion 1.0.2 `
-ReleaseNotes "Исправления и улучшения MoonWell Launcher."
```
Dry run performs the local build, packaging, signing, signature verification,
and AppCast generation. It writes the generated feed to
`build\launcher_release\appcast.xml` and does not change S3 or the website.
## Deploy to Production
After reviewing the dry-run artifacts:
```powershell
.\tool\deploy_launcher.ps1 `
-ExpectedVersion 1.0.2 `
-ReleaseNotes "Исправления и улучшения MoonWell Launcher."
```
The production defaults are:
- API: `https://moon-well.online`
- AppCast: `https://moon-well.online/appcast.xml`
- bucket: `warcraft-client`
- object: `moonwell_launcher_setup.exe`
- installer:
`https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe`
On success, the command prints the deployed version, installer URL, SHA-256,
and AppCast URL. The tracked root `appcast.xml` contains the exact feed sent to
the server.
## Safety Options
- `-ExpectedVersion X.Y.Z` prevents deploying an unintended `pubspec.yaml`
version.
- The script rejects a release version that is not newer than the public
AppCast version.
- `-Force` bypasses that version guard. Use it only to repeat an already
published version after confirming that replacing the artifact is intended.
- `-SkipChecks` skips dependency resolution, formatting, analysis, and tests.
It should not be used for a normal production release.
- `-FlutterCommand`, `-DartCommand`, `-InnoSetupCommand`, and
`-OpenSslCommand` override tool locations when they are not on `PATH`.
Run `Get-Help .\tool\deploy_launcher.ps1 -Full` or inspect the parameter block
for all endpoint, bucket, key, and signing-path overrides.
## Failure Recovery
The script stops on the first failed command.
- Failure before S3 upload leaves production unchanged.
- Failure after S3 upload but before AppCast publication leaves clients on the
preceding AppCast. Fix the issue and rerun the same release.
- Failure after AppCast publication requires checking both public URLs and the
DSA signature before using `-Force`.
- Never publish AppCast manually before the installer is publicly downloadable
and its SHA-256 matches the local artifact.
After deployment, verify:
```powershell
Invoke-WebRequest `
-Uri "https://moon-well.online/appcast.xml" `
-UseBasicParsing
Invoke-WebRequest `
-Uri "https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe" `
-Method Head `
-UseBasicParsing
```
Rotate any infrastructure credential that was exposed in terminal logs, chat,
or another non-secret channel.
+99
View File
@@ -0,0 +1,99 @@
# MoonWell Launcher Project Review
Reviewed on 2026-06-18.
## Overview
MoonWell Launcher is a Flutter desktop application for a private World of
Warcraft: Wrath of the Lich King server. Its core responsibilities are account
login, client installation/synchronization, news display, and launching
`Wow.exe`.
The project is already more than a Flutter template: it has a clear launcher
API contract, domain models for manifests and sessions, a sync use case,
installation scanning, logging, dependency injection, and tests around the
most important file verification behavior.
## Strong Points
- The project uses a readable layered structure: UI under `lib/app`, feature
code under `lib/features`, and shared primitives under `lib/core`.
- Client sync is manifest driven and verifies downloaded files by size and
SHA-256 before replacing local files.
- Local path handling normalizes manifest paths and rejects absolute paths or
traversal attempts before resolving files under the selected installation
directory.
- Player-generated or volatile client directories are excluded from
verification: `Cache`, `Errors`, `Logs`, `Screenshots`, `WTF`, and launcher
metadata.
- Hash caching is implemented as an optimization while keeping the server
manifest as the source of truth.
- Sync diagnostics are written to `.moonwell_launcher/launcher.log`, and failed
downloads can be preserved for inspection.
- The Web API integration is documented in `docs/launcher_web_api_spec.md`,
including login, manifest, presigned download, news, pause/resume, and launch
behavior.
- Tests cover deterministic build hash behavior, ignored directories, hash
cache behavior, path traversal rejection, sync success, stale file removal,
download verification failure, session restore, preferences, and news loading.
## Weak Points And Risks
- `README.md` was still the generated Flutter starter text before this review,
so new contributors had no project-specific setup or operation guide.
- There was no `AGENTS.md`, so future automated or human contributors had to
infer architecture, commands, and safety rules from source code.
- At initial review, `lib/main.dart` contained a large inactive demo
`LauncherHome` implementation. This has since been removed from the active
entrypoint.
- At initial review, several Russian UI strings appeared mojibake-encoded in
the active login and home flow. These active strings have since been
normalized to UTF-8.
- At initial review, custom title-bar widgets were duplicated between login and
home screens. They have since been extracted into a shared widget.
- Session tokens were originally persisted through `shared_preferences`. They
now use `flutter_secure_storage`, while `shared_preferences` remains limited
to non-sensitive launcher preferences.
- The dependency injection output file `lib/service_container.config.dart` is
committed, but the regeneration command was not previously documented.
- UI/widget/integration coverage is still light compared with the risk in login,
bootstrap, directory selection, news failure handling, sync controls, and
launch behavior.
- There is no visible CI configuration in the repository.
- The current working tree already had modified `pubspec.yaml` and
`pubspec.lock` before this documentation pass. Those changes were not part of
this review and should be preserved.
## Flutter 3.44.2 Upgrade Feasibility
Assumption: `3.44.2` means Flutter SDK `3.44.2`.
Current local indicators:
- `.fvmrc` is absent.
- `pubspec.yaml` declares Dart SDK `^3.9.0`.
- `pubspec.lock` declares Flutter SDK `>=3.38.0`.
- The project policy is latest stable Flutter SDK only; FVM-pinned workflows are
not supported.
- The local shell now finds Flutter `3.44.2` on the stable channel.
Flutter `3.44.2` has been validated for dependency resolution, formatting,
analysis, and tests. A production-like Windows build smoke test remains open.
Recommended upgrade path:
1. Keep the latest stable Flutter SDK on PATH.
2. Run `fvm flutter pub get`.
3. Regenerate dependency injection only if dependency resolution or generator
output requires it:
`fvm dart run build_runner build --delete-conflicting-outputs`.
4. Run `fvm flutter analyze`.
5. Run a Windows build smoke test:
`fvm flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`.
## Recommended Refactoring Direction
- Keep secure session persistence covered by tests when login or session
restore behavior changes.
- Add CI or a documented local verification workflow that runs analysis and a
Windows build smoke test with the latest stable Flutter SDK.
+330
View File
@@ -0,0 +1,330 @@
# Widgetbook Use-Case Generation Instructions
## Workspace And Asset Contract
The catalog is a standalone Flutter workspace under `widgetbook/` with a path
dependency on the launcher package. Production components live under
`lib/app/design_system`; stories, fixtures, and generated directories stay in
`widgetbook/lib`.
```powershell
cd widgetbook
fvm flutter pub get
fvm dart run build_runner build
fvm flutter run -d windows
```
The catalog provides forest and temple themes, 1024×640, 1280×720, and
1600×900 Windows viewports, and text scaling from 100% through 200%. The
production launcher composition targets a fixed minimum window of 1280×720 to
preserve the mockup layout.
The launcher compositions must follow `mockup.html`: a 240-pixel game rail,
brand and account header, hero copy over the key art, a 360-pixel floating news
panel, and the realm/progress/play strip above the status bar. The login shell
uses the mockup's split artwork and 480-pixel form panel. Widgetbook stories
exercise these production compositions; they are not alternate app layouts.
Temporary launcher artwork uses a 1536×1024 source cropped with `BoxFit.cover`.
Final artwork should be at least 2560×1440, keep essential content out of the
outer 12% on every edge, and leave the central/right content areas readable
under a dark gradient. The MoonWell brand mark is composed from Flutter vector
icons and text and must remain legible between 96 and 360 logical pixels.
Generate comprehensive Widgetbook use cases for Flutter components that showcase different variants and configurations effectively.
## Core Requirements
### UseCase Annotation Structure
Each use case must have a properly configured `@UseCase` annotation:
```dart
@UseCase(
name: 'variantName', // Required: Unique identifier for this variant
type: ComponentType, // Required: The Flutter widget class being showcased
)
```
**Naming Rules:**
- Single use case: `name: 'default'`
- Multiple use cases: Use descriptive names like `'with_label'`, `'disabled'`, `'loading'`
- Names must be unique within the same component type
### Method Signature Requirements
**Exact signature pattern:**
```dart
Widget build[ComponentName][VariantName]UseCase(BuildContext context) {
// Implementation
}
```
**Naming conventions:**
- Single use case: `buildProgressIndicatorUseCase`
- Multiple use cases: `buildProgressIndicatorWithLabelUseCase`, `buildProgressIndicatorDisabledUseCase`
- Always return `Widget`
- Always accept exactly one parameter: `BuildContext context`
## Parameter Configuration Strategy
### Priority System
1. **Critical parameters** (required, affects core functionality): Always use knobs
2. **Visual parameters** (colors, sizes, styles): Use knobs when they demonstrate component flexibility
3. **Behavioral parameters** (enabled/disabled, loading states): Use knobs for interactive demonstration
4. **Callback parameters**: Implement with descriptive print statements
5. **Complex objects**: Hardcode with meaningful defaults, add TODO comments
### Knob Selection Logic
- **Bounded numeric values**: Use sliders (opacity: 0.0-1.0, progress: 0-100)
- **Unbounded numeric values**: Use input fields (dimensions, counts)
- **Enums/predefined options**: Use list knobs
- **Text content**: Use string inputs
- **Feature toggles**: Use boolean checkboxes
## Comprehensive Knobs API
### Basic Types
```dart
// Strings
context.knobs.string(label: 'text', initialValue: 'Hello World')
context.knobs.stringOrNull(label: 'optionalText', initialValue: null)
// Booleans
context.knobs.boolean(label: 'enabled', initialValue: true)
context.knobs.booleanOrNull(label: 'optionalFlag', initialValue: null)
// Integers
context.knobs.int.input(label: 'count', initialValue: 5)
context.knobs.int.slider(label: 'progress', initialValue: 50, min: 0, max: 100, divisions: 10)
context.knobs.intOrNull.input(label: 'optionalCount', initialValue: null)
context.knobs.intOrNull.slider(label: 'progress', initialValue: null, min: 0, max: 100, divisions: 10)
// Doubles
context.knobs.double.input(label: 'value', initialValue: 1.5)
context.knobs.double.slider(label: 'opacity', initialValue: 0.8, min: 0.0, max: 1.0, divisions: 20)
context.knobs.doubleOrNull.input(label: 'value', initialValue: null)
context.knobs.doubleOrNull.slider(label: 'optionalOpacity', initialValue: null, min: 0.0, max: 1.0)
```
### Advanced Types
```dart
// Dropdown lists
context.knobs.object.dropdown<TextAlign>(
label: 'textAlign',
initialOption: TextAlign.center,
options: [TextAlign.left, TextAlign.center, TextAlign.right],
labelBuilder: (value) => value.name,
)
// Colors
context.knobs.color(label: 'primaryColor', initialValue: Colors.blue)
context.knobs.colorOrNull(label: 'optionalColor', initialValue: null)
// DateTime
context.knobs.dateTime(
label: 'selectedDate',
initialValue: DateTime.now(),
start: DateTime.now().subtract(const Duration(days: 365)),
end: DateTime.now().add(const Duration(days: 365)),
)
// Duration
context.knobs.duration(label: 'animationDuration', initialValue: const Duration(milliseconds: 300))
```
## Advanced Component Handling
### State Management Integration
For components requiring external state:
```dart
// Example: Component requiring a provider
@UseCase(name: 'with_data', type: DataWidget)
Widget buildDataWidgetWithDataUseCase(BuildContext context) {
return MockDataProvider(
data: _generateMockData(),
child: DataWidget(
onItemSelected: (item) => print('Selected item: ${item.id}'),
showLoading: context.knobs.boolean(label: 'showLoading', initialValue: false),
),
);
}
```
### Complex Parameter Handling
```dart
// For unmappable parameters
final customObject = CustomConfiguration(
// Hardcoded meaningful defaults
apiEndpoint: 'https://api.example.com',
timeout: const Duration(seconds: 30),
); // TODO: User should configure CustomConfiguration manually
// For asset references
final iconPath = 'assets/icons/star.svg'; // Verify asset exists in pubspec.yaml
```
### Callback Implementation Patterns
```dart
// Simple callbacks
onPressed: () => print('Button pressed'),
// Callbacks with data
onChanged: (value) => print('Value changed to: $value'),
// Complex callbacks
onFormSubmitted: (formData) {
print('Form submitted with data:');
print(' - Name: ${formData.name}');
print(' - Email: ${formData.email}');
},
// Async callbacks
onSave: () async {
print('Save operation started');
await Future.delayed(const Duration(seconds: 1));
print('Save operation completed');
},
```
### Theme handling
Themes are globally injected and must not be provided.
## Use Case Variant Strategies
### Single Component, Multiple Variants
Create variants that showcase different states and configurations:
```dart
// Default state
@UseCase(name: 'default', type: CustomButton)
Widget buildCustomButtonUseCase(BuildContext context) { /* ... */ }
// Loading state
@UseCase(name: 'loading', type: CustomButton)
Widget buildCustomButtonLoadingUseCase(BuildContext context) { /* ... */ }
// Disabled state
@UseCase(name: 'disabled', type: CustomButton)
Widget buildCustomButtonDisabledUseCase(BuildContext context) { /* ... */ }
// With icon
@UseCase(name: 'with icon', type: CustomButton)
Widget buildCustomButtonWithIconUseCase(BuildContext context) { /* ... */ }
```
### Responsive and Theme Variants
```dart
// Different sizes
@UseCase(name: 'small', type: ProfileCard)
@UseCase(name: 'medium', type: ProfileCard)
@UseCase(name: 'large', type: ProfileCard)
```
## Quality Standards
### Code Quality
- **No descriptive comments**: Code should be self-documenting
- **Consistent formatting**: Follow Dart style guidelines
- **Meaningful defaults**: Initial values should represent realistic usage
- **Error handling**: Wrap potentially failing operations in try-catch where appropriate
### Use Case Coverage
Ensure use cases demonstrate:
- [ ] Default/primary functionality
- [ ] Edge cases (empty states, maximum values)
- [ ] Interactive behaviors (hover, focus, disabled states)
- [ ] Visual variants (different styles, sizes, colors)
- [ ] Error states when applicable
### Testing Considerations
```dart
// Include realistic data volumes
final items = List.generate(50, (index) => 'Item ${index + 1}');
// Test boundary conditions
final progress = context.knobs.double.slider(
label: 'progress',
initialValue: 0.7,
min: 0.0,
max: 1.0,
divisions: 100,
); // Allows testing 0%, 100%, and intermediate values
```
## Complete Example Template
```dart
import 'package:flutter/material.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart';
@UseCase(
name: 'interactive',
type: CustomSlider,
)
Widget buildCustomSliderInteractiveUseCase(BuildContext context) {
return CustomSlider(
value: context.knobs.double.slider(
label: 'value',
initialValue: 0.5,
min: 0.0,
max: 1.0,
divisions: 20,
),
min: context.knobs.double.input(
label: 'min',
initialValue: 0.0,
),
max: context.knobs.double.input(
label: 'max',
initialValue: 1.0,
),
enabled: context.knobs.boolean(
label: 'enabled',
initialValue: true,
),
showLabels: context.knobs.boolean(
label: 'showLabels',
initialValue: true,
),
activeColor: context.knobs.color(
label: 'activeColor',
initialValue: Colors.blue,
),
onChanged: (value) => print('Slider value changed to: $value'),
onChangeStart: (value) => print('Slider interaction started at: $value'),
onChangeEnd: (value) => print('Slider interaction ended at: $value'),
);
}
```
## Pre-Generation Checklist
Before generating use cases, verify:
- [ ] Component class name and import path
- [ ] Required vs optional parameters
- [ ] Parameter types and constraints
- [ ] Available enum values for list knobs
- [ ] Asset dependencies and paths
- [ ] State management requirements
- [ ] Theme/localization dependencies
- [ ] Callback signatures and expected behavior
+4 -2
View File
@@ -1,6 +1,8 @@
#define MyAppName "MoonWell Launcher"
#define MyAppExeName "MoonWell.exe"
#define MyAppVersion "1.0.0"
#ifndef MyAppVersion
#define MyAppVersion "1.0.0"
#endif
#define MyAppPublisher "MoonWell"
#define MyAppURL "https://moon-well.online"
#define MyAppAssocName MyAppName + " Launcher"
@@ -19,7 +21,7 @@ DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir=..\build\installer
OutputBaseFilename=moonwell_launcher_setup
OutputBaseFilename=moonwell_launcher_{#MyAppVersion}_windows_setup
SetupIconFile=..\windows\runner\resources\app_icon.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
Compression=lzma
+8
View File
@@ -0,0 +1,8 @@
pre-commit:
commands:
format:
run: dart format --set-exit-if-changed .
analyze:
run: flutter analyze
test:
run: flutter test
@@ -0,0 +1,331 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
enum MwAuthenticationMode { login, registration }
class MwAuthenticationForm extends StatefulWidget {
const MwAuthenticationForm({
super.key,
required this.usernameController,
required this.passwordController,
required this.onSubmit,
this.emailController,
this.passwordConfirmationController,
this.inviteCodeController,
this.mode = MwAuthenticationMode.login,
this.onModeChanged,
this.termsAccepted = false,
this.onTermsChanged,
this.loading = false,
this.error,
this.success,
});
final TextEditingController usernameController;
final TextEditingController passwordController;
final TextEditingController? emailController;
final TextEditingController? passwordConfirmationController;
final TextEditingController? inviteCodeController;
final VoidCallback onSubmit;
final MwAuthenticationMode mode;
final ValueChanged<MwAuthenticationMode>? onModeChanged;
final bool termsAccepted;
final ValueChanged<bool>? onTermsChanged;
final bool loading;
final String? error;
final String? success;
@override
State<MwAuthenticationForm> createState() => _MwAuthenticationFormState();
}
class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
bool _remember = true;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final registering = widget.mode == MwAuthenticationMode.registration;
final launcherVersion = LauncherVersionScope.of(context);
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const MwBrandMark(width: 210, compact: true),
const SizedBox(height: 28),
Text(
registering ? 'РЕГИСТРАЦИЯ' : 'ВХОД',
style: TextStyle(color: accent, fontSize: 10, letterSpacing: 2.4),
),
const SizedBox(height: 10),
Text(
registering ? 'Создать аккаунт' : 'С возвращением',
style: const TextStyle(
fontFamily: 'Cinzel',
color: Color(0xFFECE4D0),
fontSize: 30,
letterSpacing: 1,
),
),
const SizedBox(height: 7),
Text(
registering
? 'Зарегистрируй игровой аккаунт MoonWell.'
: 'Войди в свой аккаунт, чтобы продолжить путь.',
style: const TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0x80ECE4D0),
fontSize: 15,
),
),
const SizedBox(height: 22),
Row(
children: [
_Tab(
label: 'ВХОД',
selected: !registering,
accent: accent,
onTap: widget.loading
? null
: () => widget.onModeChanged?.call(
MwAuthenticationMode.login,
),
),
_Tab(
label: 'РЕГИСТРАЦИЯ',
selected: registering,
accent: accent,
onTap: widget.loading
? null
: () => widget.onModeChanged?.call(
MwAuthenticationMode.registration,
),
),
],
),
const SizedBox(height: 18),
_FieldLabel(text: registering ? 'ЛОГИН' : 'ЛОГИН ИЛИ EMAIL'),
const SizedBox(height: 7),
TextField(
controller: widget.usernameController,
enabled: !widget.loading,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
decoration: const InputDecoration(hintText: 'Aranthel'),
),
if (registering) ...[
const SizedBox(height: 14),
const _FieldLabel(text: 'EMAIL'),
const SizedBox(height: 7),
TextField(
controller: widget.emailController,
enabled: !widget.loading,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(hintText: 'player@example.com'),
),
],
const SizedBox(height: 14),
_FieldLabel(text: 'ПАРОЛЬ'),
const SizedBox(height: 7),
TextField(
controller: widget.passwordController,
enabled: !widget.loading,
obscureText: true,
textInputAction: registering
? TextInputAction.next
: TextInputAction.done,
autofillHints: [
registering ? AutofillHints.newPassword : AutofillHints.password,
],
onSubmitted: registering ? null : (_) => widget.onSubmit(),
),
if (registering) ...[
const SizedBox(height: 14),
const _FieldLabel(text: 'ПОВТОРИТЕ ПАРОЛЬ'),
const SizedBox(height: 7),
TextField(
controller: widget.passwordConfirmationController,
enabled: !widget.loading,
obscureText: true,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.newPassword],
),
const SizedBox(height: 14),
const _FieldLabel(text: 'ИНВАЙТ-КОД (НЕОБЯЗАТЕЛЬНО)'),
const SizedBox(height: 7),
TextField(
controller: widget.inviteCodeController,
enabled: !widget.loading,
textInputAction: TextInputAction.done,
onSubmitted: (_) => widget.onSubmit(),
),
],
if (widget.error != null) ...[
const SizedBox(height: 10),
Text(
widget.error!,
style: const TextStyle(color: Color(0xFFD76A78), fontSize: 12),
),
],
if (widget.success != null) ...[
const SizedBox(height: 10),
Text(
widget.success!,
style: const TextStyle(color: Color(0xFF6FC28A), fontSize: 12),
),
],
const SizedBox(height: 8),
if (registering)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 24,
child: Checkbox(
value: widget.termsAccepted,
onChanged: widget.loading
? null
: (value) =>
widget.onTermsChanged?.call(value ?? false),
),
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Я принимаю правила сервера и пользовательское соглашение',
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
),
),
],
)
else
Row(
children: [
SizedBox(
width: 24,
child: Checkbox(
value: _remember,
onChanged: widget.loading
? null
: (value) => setState(() => _remember = value ?? false),
),
),
const SizedBox(width: 8),
const Text(
'Запомнить меня',
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
),
const Spacer(),
Text(
'Забыли пароль?',
style: TextStyle(color: accent, fontSize: 11),
),
],
),
const SizedBox(height: 18),
SizedBox(
height: 54,
child: ElevatedButton(
onPressed: widget.loading ? null : widget.onSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6D499E),
foregroundColor: const Color(0xFFECE4D0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: widget.loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
registering ? 'СОЗДАТЬ АККАУНТ' : 'ВОЙТИ',
style: const TextStyle(
fontFamily: 'Cinzel',
letterSpacing: 2.5,
),
),
const SizedBox(width: 14),
const Icon(Icons.arrow_forward, size: 18),
],
),
),
),
const SizedBox(height: 28),
Center(
child: Text(
'MOONWELL · V $launcherVersion · PLAY.MOON-WELL.ONLINE',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x52ECE4D0),
fontSize: 8,
letterSpacing: 1.2,
),
),
),
],
),
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel({required this.text});
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x80ECE4D0),
fontSize: 9,
letterSpacing: 1.5,
),
);
}
class _Tab extends StatelessWidget {
const _Tab({
required this.label,
required this.accent,
this.onTap,
this.selected = false,
});
final String label;
final Color accent;
final VoidCallback? onTap;
final bool selected;
@override
Widget build(BuildContext context) => Expanded(
child: InkWell(
onTap: onTap,
child: Container(
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: selected ? accent : Colors.transparent),
),
),
child: Text(
label,
style: TextStyle(
fontFamily: 'Cinzel',
color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
fontSize: 9,
letterSpacing: 1.5,
),
),
),
),
);
}
@@ -0,0 +1,850 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/design_system/mw_primitives.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
const _ivory = Color(0xFFECE4D0);
const _muted = Color(0x80ECE4D0);
const _border = Color(0x247FB8D4);
class MwGameTile extends StatelessWidget {
const MwGameTile({
super.key,
required this.title,
required this.icon,
this.subtitle,
this.selected = false,
this.enabled = true,
this.compact = false,
this.onPressed,
this.mark,
});
final String title;
final String? subtitle;
final IconData icon;
final bool selected;
final bool enabled;
final bool compact;
final VoidCallback? onPressed;
final String? mark;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return Opacity(
opacity: enabled ? 1 : .58,
child: Material(
color: selected ? accent.withAlpha(25) : accent.withAlpha(8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(color: selected ? accent : _border),
),
child: InkWell(
onTap: enabled ? onPressed : null,
borderRadius: BorderRadius.circular(6),
child: SizedBox(
height: 52,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [const Color(0xFF5B3A8A), accent],
),
borderRadius: BorderRadius.circular(6),
),
child: Text(
mark ?? title.characters.first,
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 12,
),
),
),
if (!compact) ...[
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 10,
letterSpacing: .8,
),
),
const SizedBox(height: 3),
Text(
(subtitle ?? (enabled ? 'OPEN BETA' : 'СКОРО'))
.toUpperCase(),
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: selected ? accent : _muted,
fontSize: 8,
letterSpacing: 1.1,
),
),
],
),
),
if (!enabled && subtitle == null)
const Text(
'SOON',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 7,
letterSpacing: 1,
),
),
],
],
),
),
),
),
),
);
}
}
class MwGameRail extends StatelessWidget {
const MwGameRail({
super.key,
this.compact = false,
this.onSettings,
this.username,
});
final bool compact;
final VoidCallback? onSettings;
final String? username;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final normalizedUsername = username?.trim() ?? '';
final userInitial = normalizedUsername.isEmpty
? '?'
: normalizedUsername.characters.first.toUpperCase();
return Container(
padding: const EdgeInsets.fromLTRB(14, 48, 14, 30),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xB5070A18), Color(0x8F070A18)],
),
border: Border(right: BorderSide(color: _border)),
),
child: Column(
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [accent, const Color(0xFF5B3A8A)],
),
border: Border.all(color: accent),
boxShadow: [
BoxShadow(color: accent.withAlpha(70), blurRadius: 12),
],
),
child: Text(
userInitial,
style: const TextStyle(fontFamily: 'Cinzel'),
),
),
const SizedBox(height: 14),
const Divider(color: _border),
const Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 7, 0, 8),
child: Text(
'ИГРЫ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 9,
letterSpacing: 2.2,
),
),
),
),
MwGameTile(
title: 'MoonWell · WoW',
subtitle: 'Open Beta',
mark: 'MW',
icon: Icons.nights_stay,
selected: true,
compact: compact,
onPressed: () {},
),
const SizedBox(height: 8),
MwGameTile(
title: 'MoonWell Karts',
subtitle: 'В разработке',
mark: 'K',
icon: Icons.sports_motorsports,
enabled: false,
compact: compact,
),
const Spacer(),
const Divider(color: _border),
Row(
children: [
IconButton(
onPressed: null,
tooltip: 'Друзья',
icon: const Icon(Icons.people_outline, size: 18),
),
const Spacer(),
IconButton(
onPressed: onSettings,
tooltip: 'Настройки',
icon: Icon(
Icons.settings_outlined,
color: accent.withAlpha(150),
size: 18,
),
),
],
),
],
),
);
}
}
class MwNewsItemData {
const MwNewsItemData({
required this.title,
required this.body,
this.createdAt,
this.imageUrl,
this.onPressed,
});
final String title;
final String body;
final DateTime? createdAt;
final String? imageUrl;
final VoidCallback? onPressed;
}
class MwNewsItem extends StatelessWidget {
const MwNewsItem({super.key, required this.item});
final MwNewsItemData item;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final date = item.createdAt?.toLocal();
return Material(
color: Colors.transparent,
child: InkWell(
onTap: item.onPressed,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 13, 12, 13),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'НОВОСТЬ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 8,
letterSpacing: 1.5,
),
),
),
const Spacer(),
Text(
date == null
? 'СЕГОДНЯ'
: '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 9,
),
),
],
),
const SizedBox(height: 7),
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Cormorant Garamond',
color: _ivory,
fontSize: 16,
height: 1.25,
),
),
],
),
),
),
);
}
}
enum MwNewsListState { loading, empty, failure, populated }
class MwNewsList extends StatelessWidget {
const MwNewsList({
super.key,
required this.state,
this.items = const [],
this.error,
this.onRetry,
});
final MwNewsListState state;
final List<MwNewsItemData> items;
final String? error;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return Container(
decoration: BoxDecoration(
color: const Color(0xD00A0D1F),
border: Border.all(color: const Color(0x477FB8D4)),
borderRadius: BorderRadius.circular(10),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
SizedBox(
height: 45,
child: Row(
children: [
_NewsTab(label: 'НОВОСТИ', selected: true, accent: accent),
],
),
),
const Divider(height: 1, color: _border),
Expanded(child: _content()),
Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: _border)),
),
child: Row(
children: [
const Text(
'MOON-WELL.ONLINE',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 9,
letterSpacing: 1.5,
),
),
const Spacer(),
Text(
'ВСЕ →',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 9,
letterSpacing: 1.5,
),
),
],
),
),
],
),
);
}
Widget _content() => switch (state) {
MwNewsListState.loading => const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
MwNewsListState.empty => const Center(
child: Text('Новостей пока нет', style: TextStyle(color: _muted)),
),
MwNewsListState.failure => Center(
child: Text(
error ?? 'Не удалось загрузить новости',
style: const TextStyle(color: Color(0xFFD76A78)),
),
),
MwNewsListState.populated => ListView.separated(
padding: const EdgeInsets.only(left: 16, right: 4),
itemCount: items.length,
separatorBuilder: (_, _) => const Divider(height: 1, color: _border),
itemBuilder: (_, index) => MwNewsItem(item: items[index]),
),
};
}
class _NewsTab extends StatelessWidget {
const _NewsTab({
required this.label,
required this.accent,
this.selected = false,
});
final String label;
final Color accent;
final bool selected;
@override
Widget build(BuildContext context) => Expanded(
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? accent.withAlpha(12) : Colors.transparent,
border: Border(
bottom: BorderSide(color: selected ? accent : Colors.transparent),
),
),
child: Text(
label,
style: TextStyle(
fontFamily: 'Cinzel',
color: selected ? _ivory : _muted,
fontSize: 9,
letterSpacing: 1.5,
),
),
),
);
}
enum MwSyncPresentation {
install,
scanning,
downloading,
pausing,
paused,
verifying,
ready,
launched,
failure,
}
class MwSyncActionArea extends StatelessWidget {
const MwSyncActionArea({
super.key,
required this.state,
required this.status,
required this.onPrimary,
this.onPause,
this.progress,
this.currentPath,
this.error,
this.realmName,
this.realmOnline,
this.realmGameBuild,
});
final MwSyncPresentation state;
final String status;
final VoidCallback onPrimary;
final VoidCallback? onPause;
final double? progress;
final String? currentPath;
final String? error;
final String? realmName;
final bool? realmOnline;
final int? realmGameBuild;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final ready = state == MwSyncPresentation.ready;
final active = {
MwSyncPresentation.scanning,
MwSyncPresentation.downloading,
MwSyncPresentation.pausing,
MwSyncPresentation.verifying,
}.contains(state);
final value = ready || state == MwSyncPresentation.launched
? 1.0
: (progress ?? 0);
final realmStatus = switch (realmOnline) {
true => 'ONLINE',
false => 'OFFLINE',
null => 'STATUS UNKNOWN',
};
final realmMetadata = realmGameBuild != null && realmGameBuild! > 0
? '$realmStatus · BUILD $realmGameBuild'
: realmStatus;
final label = switch (state) {
MwSyncPresentation.install => 'УСТАНОВИТЬ',
MwSyncPresentation.paused => 'ПРОДОЛЖИТЬ',
MwSyncPresentation.ready => 'ИГРАТЬ',
MwSyncPresentation.launched => 'ИГРА ЗАПУЩЕНА',
MwSyncPresentation.failure => 'ПОВТОРИТЬ',
_ => 'ПАУЗА',
};
return Container(
padding: const EdgeInsets.fromLTRB(32, 18, 32, 20),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x0006091A), Color(0xF206091A)],
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: const Color(0x99070A18),
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
_PulseDot(online: realmOnline),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(realmName?.trim().isNotEmpty ?? false)
? realmName!.toUpperCase()
: 'REALM UNAVAILABLE',
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 11,
letterSpacing: 1.5,
),
),
const SizedBox(height: 3),
Text(
realmMetadata,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
letterSpacing: 1.1,
),
),
],
),
],
),
),
const SizedBox(width: 24),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
error ?? status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: error == null
? accent
: const Color(0xFFD76A78),
fontSize: 10,
letterSpacing: 1.1,
),
),
),
Text(
'${(value * 100).round()}%',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _ivory,
fontSize: 13,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: value,
minHeight: 4,
color: accent,
backgroundColor: accent.withAlpha(28),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
currentPath ??
(ready ? 'Все файлы целы' : 'Подготовка клиента'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
),
),
),
if (ready)
const Flexible(
child: Text(
'Последняя проверка: только что',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
),
),
),
],
),
],
),
),
const SizedBox(width: 24),
SizedBox(
width: 220,
height: 64,
child: ElevatedButton.icon(
onPressed: active
? onPause
: (state == MwSyncPresentation.launched ? null : onPrimary),
icon: Icon(
ready
? Icons.play_arrow
: active
? Icons.pause
: Icons.refresh,
size: 20,
),
label: Text(label),
style: ElevatedButton.styleFrom(
backgroundColor: ready
? const Color(0xFF60B17F)
: state == MwSyncPresentation.failure
? const Color(0xFFD76A78)
: const Color(0xFF6D499E),
foregroundColor: ready ? const Color(0xFF07130F) : _ivory,
textStyle: const TextStyle(
fontFamily: 'Cinzel',
fontSize: 14,
letterSpacing: 2.4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
),
),
],
),
);
}
}
class _PulseDot extends StatelessWidget {
const _PulseDot({required this.online});
final bool? online;
@override
Widget build(BuildContext context) {
final color = switch (online) {
true => const Color(0xFF6FC28A),
false => const Color(0xFFD76A78),
null => _muted,
};
return Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: color.withAlpha(170), blurRadius: 8)],
),
);
}
}
class MwLauncherStatusBar extends StatelessWidget {
const MwLauncherStatusBar({
super.key,
required this.status,
this.fileCount,
this.speed,
this.eta,
this.buildHash,
});
final String status;
final String? fileCount;
final String? speed;
final String? eta;
final String? buildHash;
@override
Widget build(BuildContext context) {
final launcherVersion = LauncherVersionScope.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: const BoxDecoration(
color: Color(0xD904060E),
border: Border(top: BorderSide(color: _border)),
),
child: DefaultTextStyle(
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
letterSpacing: .8,
),
child: Row(
children: [
Expanded(
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
),
if (fileCount != null) Text(fileCount!),
if (speed != null) ...[const SizedBox(width: 24), Text(speed!)],
if (eta != null) ...[const SizedBox(width: 24), Text(eta!)],
if (buildHash != null) ...[
const SizedBox(width: 24),
Text(buildHash!),
],
const SizedBox(width: 24),
Text('Лаунчер v$launcherVersion'),
],
),
),
);
}
}
class MwAccountAffordance extends StatelessWidget {
const MwAccountAffordance({super.key, required this.onLogout, this.username});
final VoidCallback onLogout;
final String? username;
@override
Widget build(BuildContext context) {
final displayUsername = (username?.trim().isNotEmpty ?? false)
? username!.trim()
: 'Аккаунт';
final initial = displayUsername.characters.first.toUpperCase();
return PopupMenuButton<String>(
tooltip: 'Аккаунт',
onSelected: (_) => onLogout(),
itemBuilder: (_) => const [
PopupMenuItem(value: 'logout', child: Text('Выйти')),
],
child: Container(
padding: const EdgeInsets.fromLTRB(8, 7, 15, 7),
decoration: BoxDecoration(
color: const Color(0x99070A18),
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(99),
),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: const Color(0xFF5B3A8A),
child: Text(
initial,
style: const TextStyle(fontFamily: 'Cinzel', fontSize: 12),
),
),
const SizedBox(width: 11),
Text(
displayUsername,
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 12,
letterSpacing: 1,
),
),
],
),
),
);
}
}
class MwSettingsPanel extends StatelessWidget {
const MwSettingsPanel({
super.key,
required this.installationPath,
required this.onChooseDirectory,
required this.onVerify,
this.syncActive = false,
});
final String? installationPath;
final VoidCallback onChooseDirectory;
final VoidCallback onVerify;
final bool syncActive;
@override
Widget build(BuildContext context) => MwPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Настройки', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 20),
const Text('Папка установки'),
const SizedBox(height: 6),
MwStatusText(
text: installationPath ?? 'Папка не выбрана',
monospace: true,
),
const SizedBox(height: 8),
MwButton(
label: 'Выбрать папку',
icon: Icons.folder_open_outlined,
onPressed: syncActive ? null : onChooseDirectory,
variant: MwButtonVariant.secondary,
),
const SizedBox(height: 20),
MwButton(
label: 'Проверить файлы',
onPressed: syncActive ? null : onVerify,
variant: MwButtonVariant.secondary,
),
],
),
);
}
+448
View File
@@ -0,0 +1,448 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
enum MwButtonVariant { primary, secondary, ghost, destructive }
class MwButton extends StatelessWidget {
const MwButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
this.variant = MwButtonVariant.primary,
this.loading = false,
this.expand = false,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final MwButtonVariant variant;
final bool loading;
final bool expand;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final child = loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 18),
const SizedBox(width: 8),
],
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
],
);
final callback = loading ? null : onPressed;
final button = switch (variant) {
MwButtonVariant.primary => ElevatedButton(
onPressed: callback,
child: child,
),
MwButtonVariant.secondary => OutlinedButton(
onPressed: callback,
child: child,
),
MwButtonVariant.ghost => TextButton(onPressed: callback, child: child),
MwButtonVariant.destructive => ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
backgroundColor: scheme.error,
foregroundColor: scheme.onError,
disabledBackgroundColor: scheme.error.withAlpha(50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.radiusMedium),
),
),
child: child,
),
};
return Semantics(
button: true,
enabled: callback != null,
child: SizedBox(width: expand ? double.infinity : null, child: button),
);
}
}
class MwIconButton extends StatelessWidget {
const MwIconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.selected = false,
this.destructive = false,
});
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final bool selected;
final bool destructive;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: Semantics(
label: tooltip,
button: true,
enabled: onPressed != null,
selected: selected,
child: IconButton(
onPressed: onPressed,
isSelected: selected,
icon: Icon(icon),
color: destructive ? scheme.error : scheme.onSurfaceVariant,
style: IconButton.styleFrom(minimumSize: const Size(44, 44)),
),
),
);
}
}
class MwTextField extends StatelessWidget {
const MwTextField({
super.key,
required this.label,
this.controller,
this.hint,
this.errorText,
this.prefixIcon,
this.enabled = true,
this.obscureText = false,
this.textInputAction,
this.onSubmitted,
});
final String label;
final TextEditingController? controller;
final String? hint;
final String? errorText;
final IconData? prefixIcon;
final bool enabled;
final bool obscureText;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
enabled: enabled,
obscureText: obscureText,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
decoration: InputDecoration(
labelText: label,
hintText: hint,
errorText: errorText,
prefixIcon: prefixIcon == null ? null : Icon(prefixIcon),
),
);
}
}
class MwCheckbox extends StatelessWidget {
const MwCheckbox({
super.key,
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool?>? onChanged;
@override
Widget build(BuildContext context) {
return CheckboxListTile(
value: value,
onChanged: onChanged,
title: Text(label),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
);
}
}
class MwTabs extends StatelessWidget {
const MwTabs({
super.key,
required this.labels,
required this.selectedIndex,
required this.onSelected,
this.disabledIndices = const {},
});
final List<String> labels;
final int selectedIndex;
final ValueChanged<int> onSelected;
final Set<int> disabledIndices;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 4,
children: [
for (var index = 0; index < labels.length; index++)
ChoiceChip(
label: Text(labels[index]),
selected: selectedIndex == index,
onSelected: disabledIndices.contains(index)
? null
: (_) => onSelected(index),
),
],
);
}
}
enum MwBadgeTone { neutral, success, warning, error, comingSoon }
class MwBadge extends StatelessWidget {
const MwBadge({
super.key,
required this.label,
this.tone = MwBadgeTone.neutral,
});
final String label;
final MwBadgeTone tone;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final color = switch (tone) {
MwBadgeTone.neutral => scheme.onSurfaceVariant,
MwBadgeTone.success => tokens.success,
MwBadgeTone.warning => tokens.warning,
MwBadgeTone.error => scheme.error,
MwBadgeTone.comingSoon => tokens.accentMuted,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(24),
border: Border.all(color: color.withAlpha(130)),
borderRadius: BorderRadius.circular(99),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
);
}
}
enum MwPanelLevel { low, regular, high }
class MwPanel extends StatelessWidget {
const MwPanel({
super.key,
required this.child,
this.level = MwPanelLevel.regular,
this.padding = const EdgeInsets.all(24),
});
final Widget child;
final MwPanelLevel level;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final color = switch (level) {
MwPanelLevel.low => tokens.surfaceLow,
MwPanelLevel.regular => tokens.surface,
MwPanelLevel.high => tokens.surfaceHigh,
};
return DecoratedBox(
decoration: BoxDecoration(
color: color.withAlpha(238),
border: Border.fromBorderSide(tokens.border),
borderRadius: BorderRadius.circular(tokens.radiusLarge),
boxShadow: tokens.panelShadow,
),
child: Padding(padding: padding, child: child),
);
}
}
enum MwProgressState { active, paused, completed, failure }
class MwProgressBar extends StatelessWidget {
const MwProgressBar({
super.key,
this.value,
this.label,
this.state = MwProgressState.active,
});
final double? value;
final String? label;
final MwProgressState state;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final color = switch (state) {
MwProgressState.active => scheme.primary,
MwProgressState.paused => tokens.warning,
MwProgressState.completed => tokens.success,
MwProgressState.failure => scheme.error,
};
return Semantics(
label: label ?? 'Прогресс',
value: value == null ? null : '${(value! * 100).round()}%',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (label != null) ...[
Text(label!, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 8),
],
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: LinearProgressIndicator(
value: value,
color: color,
backgroundColor: color.withAlpha(35),
minHeight: 7,
),
),
],
),
);
}
}
enum MwStatusTone { operational, success, warning, error }
class MwStatusText extends StatelessWidget {
const MwStatusText({
super.key,
required this.text,
this.tone = MwStatusTone.operational,
this.monospace = false,
});
final String text;
final MwStatusTone tone;
final bool monospace;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final color = switch (tone) {
MwStatusTone.operational => scheme.onSurfaceVariant,
MwStatusTone.success => tokens.success,
MwStatusTone.warning => tokens.warning,
MwStatusTone.error => scheme.error,
};
return Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: color,
fontFamily: monospace ? tokens.monospaceFontFamily : null,
),
);
}
}
enum MwStateKind { loading, empty, error }
class MwStateView extends StatelessWidget {
const MwStateView({
super.key,
required this.kind,
required this.title,
this.description,
this.onRetry,
});
final MwStateKind kind;
final String title;
final String? description;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final icon = switch (kind) {
MwStateKind.loading => null,
MwStateKind.empty => Icons.inbox_outlined,
MwStateKind.error => Icons.cloud_off_outlined,
};
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon == null)
const SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Icon(icon, size: 34),
const SizedBox(height: 12),
Text(title, textAlign: TextAlign.center),
if (description != null) ...[
const SizedBox(height: 6),
Text(
description!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
if (onRetry != null) ...[
const SizedBox(height: 16),
MwButton(
label: 'Повторить',
onPressed: onRetry,
variant: MwButtonVariant.secondary,
),
],
],
),
),
);
}
}
class MwDisabledFeature extends StatelessWidget {
const MwDisabledFeature({
super.key,
required this.message,
required this.child,
});
final String message;
final Widget child;
@override
Widget build(BuildContext context) {
return Tooltip(message: message, child: child);
}
}
+541
View File
@@ -0,0 +1,541 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
import 'package:moonwell_launcher/app/design_system/mw_window_chrome.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
const _ink = Color(0xFF06091A);
const _ivory = Color(0xFFECE4D0);
class MwBrandMark extends StatelessWidget {
const MwBrandMark({super.key, this.width = 220, this.compact = false});
final double width;
final bool compact;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return Semantics(
image: true,
label: 'MoonWell',
child: SizedBox(
width: width,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_MoonMark(size: compact ? 28 : 40, color: accent),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MOONWELL',
style: TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: compact ? 15 : 22,
height: 1,
letterSpacing: compact ? 4 : 6,
),
),
if (!compact) ...[
const SizedBox(height: 5),
Text(
'LUNAR · LAUNCHER',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 9,
letterSpacing: 2.6,
),
),
],
],
),
],
),
),
),
);
}
}
class _MoonMark extends StatelessWidget {
const _MoonMark({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: size,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: color.withAlpha(90)),
boxShadow: [BoxShadow(color: color.withAlpha(70), blurRadius: 16)],
),
child: Icon(Icons.nightlight_round, color: color, size: size * .72),
),
);
}
}
class MwArtworkBackground extends StatelessWidget {
const MwArtworkBackground({
super.key,
required this.child,
this.assetPackage,
});
final Widget child;
final String? assetPackage;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: _ink),
// Image.asset(
// 'assets/background.png',
// package: assetPackage,
// fit: BoxFit.cover,
// alignment: Alignment.center,
// errorBuilder: (_, _, _) => const SizedBox.shrink(),
// ),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
_ink.withAlpha(235),
_ink.withAlpha(85),
_ink.withAlpha(165),
],
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: const Alignment(.35, -.5),
radius: .8,
colors: [accent.withAlpha(42), Colors.transparent],
),
),
),
child,
],
);
}
}
class MwLoginShell extends StatelessWidget {
const MwLoginShell({
super.key,
required this.form,
required this.onDrag,
required this.onDoubleTap,
required this.onMinimize,
required this.onMaximizeRestore,
required this.onClose,
this.assetPackage,
});
final Widget form;
final VoidCallback onDrag;
final VoidCallback onDoubleTap;
final VoidCallback onMinimize;
final VoidCallback onMaximizeRestore;
final VoidCallback onClose;
final String? assetPackage;
@override
Widget build(BuildContext context) {
return Scaffold(
body: MwArtworkBackground(
assetPackage: assetPackage,
child: Stack(
children: [
Positioned.fill(
child: Row(
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(
'assets/login_key_art.png',
package: assetPackage,
fit: BoxFit.cover,
alignment: Alignment.center,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, .52, 1],
colors: [
Color(0x00070A18),
Color(0x22070A18),
Color(0xF2070A18),
],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0x33070A18),
Color(0x00070A18),
Color(0x8F070A18),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(64, 80, 64, 58),
child: Align(
alignment: Alignment.bottomLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'✦',
style: TextStyle(
color: Color(0xFF7FB8D4),
fontSize: 20,
),
),
SizedBox(height: 12),
Text(
'«Звёздный свет проведёт тебя сквозь самые '
'тёмные ночи — если ты готов идти туда, '
'куда он указывает.»',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0xC6ECE4D0),
fontSize: 22,
height: 1.5,
),
),
SizedBox(height: 18),
Text(
'— МАЛФУРИОН ЯРОСТЬ БУРИ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x80ECE4D0),
fontSize: 10,
letterSpacing: 2,
),
),
],
),
),
),
),
],
),
),
Container(
width: 480,
decoration: const BoxDecoration(
color: Color(0xF20A0D1F),
border: Border(
left: BorderSide(color: Color(0x247FB8D4)),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(56, 64, 56, 58),
child: form,
),
),
],
),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: MwWindowChrome(
onDrag: onDrag,
onDoubleTap: onDoubleTap,
onMinimize: onMinimize,
onMaximizeRestore: onMaximizeRestore,
onClose: onClose,
showMaximize: false,
),
),
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 26,
child: _LoginStatusBar(),
),
],
),
),
);
}
}
class _LoginStatusBar extends StatelessWidget {
const _LoginStatusBar();
@override
Widget build(BuildContext context) {
final launcherVersion = LauncherVersionScope.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: const BoxDecoration(
color: Color(0xD904060E),
border: Border(top: BorderSide(color: Color(0x247FB8D4))),
),
child: Row(
children: [
Text('Версия лаунчера $launcherVersion'),
const SizedBox(width: 24),
const Text('·'),
const SizedBox(width: 24),
const Text('Соединение защищено TLS'),
const Spacer(),
const Text('© 2026 MoonWell'),
],
),
);
}
}
class MwLauncherShell extends StatelessWidget {
const MwLauncherShell({
super.key,
required this.news,
required this.syncActions,
required this.statusBar,
required this.account,
required this.onDrag,
required this.onDoubleTap,
required this.onMinimize,
required this.onMaximizeRestore,
required this.onClose,
this.onSettings,
this.username,
this.assetPackage,
});
final Widget news;
final Widget syncActions;
final Widget statusBar;
final Widget account;
final VoidCallback onDrag;
final VoidCallback onDoubleTap;
final VoidCallback onMinimize;
final VoidCallback onMaximizeRestore;
final VoidCallback onClose;
final VoidCallback? onSettings;
final String? username;
final String? assetPackage;
@override
Widget build(BuildContext context) {
return Scaffold(
body: MwArtworkBackground(
assetPackage: assetPackage,
child: Stack(
children: [
Positioned(
left: 240,
right: 0,
top: 0,
bottom: 26,
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(
'assets/launcher_key_art.png',
package: assetPackage,
fit: BoxFit.cover,
alignment: Alignment.center,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
stops: [0, .3, .7, 1],
colors: [
Color(0xF2070A18),
Color(0xB8070A18),
Color(0x22070A18),
Color(0x73070A18),
],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, .58, 1],
colors: [
Color(0x52070A18),
Color(0x00070A18),
Color(0xD9070A18),
],
),
),
),
],
),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 240,
child: MwGameRail(onSettings: onSettings, username: username),
),
Positioned(
left: 272,
top: 24,
child: const MwBrandMark(width: 245),
),
Positioned(right: 28, top: 48, child: account),
const Positioned(left: 272, top: 160, child: _HeroCopy()),
Positioned(
right: 24,
top: 110,
bottom: 130,
width: 360,
child: news,
),
Positioned(
left: 240,
right: 0,
bottom: 26,
height: 110,
child: syncActions,
),
Positioned(
left: 240,
right: 0,
bottom: 0,
height: 26,
child: statusBar,
),
Positioned(
left: 0,
right: 0,
top: 0,
child: MwWindowChrome(
onDrag: onDrag,
onDoubleTap: onDoubleTap,
onMinimize: onMinimize,
onMaximizeRestore: onMaximizeRestore,
onClose: onClose,
showMaximize: false,
),
),
],
),
),
);
}
}
class _HeroCopy extends StatelessWidget {
const _HeroCopy();
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return SizedBox(
width: 520,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(99),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Text(
'OPEN BETA',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 11,
letterSpacing: 2.4,
),
),
],
),
),
const SizedBox(height: 18),
const Text(
'Свет колодца',
style: TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 50,
height: 1.05,
letterSpacing: 1.5,
),
),
Text(
'зовёт тебя домой.',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: accent,
fontSize: 54,
height: 1.05,
),
),
const SizedBox(height: 14),
const Text(
'Wrath of the Lich King · приватный сервер',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0xC6ECE4D0),
fontSize: 18,
),
),
],
),
);
}
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
class MwWindowChrome extends StatelessWidget {
const MwWindowChrome({
super.key,
required this.onDrag,
required this.onDoubleTap,
required this.onMinimize,
required this.onMaximizeRestore,
required this.onClose,
this.showMaximize = true,
});
final VoidCallback onDrag;
final VoidCallback onDoubleTap;
final VoidCallback onMinimize;
final VoidCallback onMaximizeRestore;
final VoidCallback onClose;
final bool showMaximize;
@override
Widget build(BuildContext context) {
return Container(
height: 40.0,
padding: .symmetric(horizontal: 12.0),
child: Row(
spacing: 4.0,
children: [
const Spacer(),
_WindowButton(
icon: Icons.remove,
label: 'Свернуть',
onPressed: onMinimize,
),
if (showMaximize)
_WindowButton(
icon: Icons.crop_square,
label: 'Развернуть или восстановить',
onPressed: onMaximizeRestore,
),
_WindowButton(
icon: Icons.close,
label: 'Закрыть',
destructive: true,
onPressed: onClose,
),
],
),
);
}
}
class _WindowButton extends StatelessWidget {
const _WindowButton({
required this.icon,
required this.label,
required this.onPressed,
this.destructive = false,
});
final IconData icon;
final String label;
final VoidCallback onPressed;
final bool destructive;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: label,
child: Semantics(
button: true,
label: label,
child: IconButton(
onPressed: onPressed,
icon: Icon(icon, size: 16),
color: scheme.onSurfaceVariant,
hoverColor: destructive
? scheme.error.withAlpha(180)
: scheme.onSurface.withAlpha(20),
focusColor: scheme.primary.withAlpha(50),
style: IconButton.styleFrom(
tapTargetSize: .shrinkWrap,
visualDensity: VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
fixedSize: const Size(36.0, 28.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4.0),
),
),
),
),
);
}
}
+177 -6
View File
@@ -26,12 +26,14 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
required PreferencesRepository preferencesRepository,
required LauncherSession session,
required ClientManifest manifest,
Duration manifestRefreshInterval = const Duration(minutes: 5),
Duration realmRefreshInterval = const Duration(minutes: 1),
}) : _clientSyncUseCase = clientSyncUseCase,
_gameInstallationService = gameInstallationService,
_launcherApiClient = launcherApiClient,
_preferencesRepository = preferencesRepository,
_session = session,
_manifest = manifest,
_startupManifest = manifest,
super(
HomeScreenState(
model: HomeScreenModel.initial().copyWith(
@@ -42,14 +44,28 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
) {
on<HomeScreenLoad>(_onHomeScreenLoad);
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
on<HomeScreenManifestRefreshRequested>(
_onHomeScreenManifestRefreshRequested,
);
on<HomeScreenRealmRefreshRequested>(_onHomeScreenRealmRefreshRequested);
on<HomeScreenAccountLoadRequested>(_onHomeScreenAccountLoadRequested);
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
on<HomeScreenGameExited>(_onHomeScreenGameExited);
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
add(HomeScreenLoad());
_manifestRefreshTimer = Timer.periodic(
manifestRefreshInterval,
(_) => add(HomeScreenManifestRefreshRequested()),
);
_realmRefreshTimer = Timer.periodic(
realmRefreshInterval,
(_) => add(HomeScreenRealmRefreshRequested()),
);
}
final ClientSyncUseCase _clientSyncUseCase;
@@ -59,7 +75,10 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
StreamSubscription<ClientSyncStatus>? _syncSubscription;
final LauncherSession _session;
final ClientManifest _manifest;
final ClientManifest _startupManifest;
Timer? _manifestRefreshTimer;
Timer? _realmRefreshTimer;
int? _activeGamePid;
bool _pauseRequested = false;
Future<void> _onHomeScreenLoad(
@@ -84,6 +103,12 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
),
);
if (outputPath != null) {
add(HomeScreenSyncRequested(manifest: _startupManifest));
}
add(HomeScreenRealmRefreshRequested());
add(HomeScreenAccountLoadRequested());
try {
final newsItems = await _launcherApiClient.fetchNews(
_session.accessToken,
@@ -109,11 +134,101 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
}
}
Future<void> _onHomeScreenAccountLoadRequested(
HomeScreenAccountLoadRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isAuthenticated) {
return;
}
try {
final account = await _launcherApiClient.fetchAccount(
_session.accessToken,
);
if (!state.model.isAuthenticated) {
return;
}
emit(
HomeScreenState(
model: state.model.copyWith(
accountUsername: account.username.isEmpty ? null : account.username,
),
),
);
} catch (_) {
// Account metadata does not block patching or launching the game.
}
}
Future<void> _onHomeScreenRealmRefreshRequested(
HomeScreenRealmRefreshRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isAuthenticated) {
return;
}
try {
final realms = await _launcherApiClient.fetchRealms();
if (!state.model.isAuthenticated) {
return;
}
emit(
HomeScreenState(
model: state.model.copyWith(
realm: realms.isEmpty ? null : realms.first,
),
),
);
} catch (_) {
// Keep the last known realm state and retry on the next timer tick.
}
}
Future<void> _onHomeScreenManifestRefreshRequested(
HomeScreenManifestRefreshRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isAuthenticated ||
state.model.isSyncing ||
state.model.isGameRunning ||
state.model.phase == HomeScreenPhase.paused ||
state.model.outputPath == null) {
return;
}
try {
final manifest = await _launcherApiClient.fetchManifest(
_session.accessToken,
);
if (state.model.isSyncing || !state.model.isAuthenticated) {
return;
}
if (manifest.buildHash == state.model.localBuildHash) {
if (manifest.buildHash != state.model.remoteBuildHash) {
emit(
HomeScreenState(
model: state.model.copyWith(remoteBuildHash: manifest.buildHash),
),
);
}
return;
}
add(HomeScreenSyncRequested(manifest: manifest));
} catch (_) {
// Retry on the next timer tick without replacing the stable UI state.
}
}
Future<void> _onHomeScreenSyncRequested(
HomeScreenSyncRequested event,
Emitter<HomeScreenState> emit,
) async {
if (state.model.isSyncing) {
if (state.model.isSyncing || state.model.isGameRunning) {
return;
}
@@ -136,6 +251,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
errorMessage: null,
processedFiles: 0,
totalFiles: 0,
syncStage: null,
),
),
);
@@ -146,7 +262,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
request: ClientSyncRequest(
installationDir: outputPath.toFilePath(),
accessToken: _session.accessToken,
manifest: _manifest,
manifest: event.manifest,
),
isCancelled: () async => _pauseRequested,
),
@@ -165,7 +281,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
return;
}
final directory = await FilePicker.platform.getDirectoryPath(
final directory = await FilePicker.getDirectoryPath(
dialogTitle: 'Выберите папку установки Moonwell',
);
@@ -230,12 +346,30 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
try {
final installationDir = outputPath.toFilePath();
await _gameInstallationService.clearCache(installationDir);
await _gameInstallationService.launchGame(installationDir);
final process = await _gameInstallationService.launchGame(
installationDir,
);
_activeGamePid = process.pid;
unawaited(
process.exitCode.then<void>(
(exitCode) {
if (!isClosed) {
add(HomeScreenGameExited(pid: process.pid, exitCode: exitCode));
}
},
onError: (_) {
if (!isClosed) {
add(HomeScreenGameExited(pid: process.pid, exitCode: null));
}
},
),
);
emit(
HomeScreenState(
model: state.model.copyWith(
phase: HomeScreenPhase.readyToPlay,
isGameRunning: true,
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
errorMessage: null,
),
@@ -251,11 +385,39 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
}
}
Future<void> _onHomeScreenGameExited(
HomeScreenGameExited event,
Emitter<HomeScreenState> emit,
) async {
if (_activeGamePid != event.pid) {
return;
}
_activeGamePid = null;
final hasClientExecutable = await _resolveExecutablePresence(
state.model.outputPath,
);
emit(
HomeScreenState(
model: state.model.copyWith(
phase: _stablePhase(hasClientExecutable),
hasClientExecutable: hasClientExecutable,
isGameRunning: false,
statusText: hasClientExecutable
? 'Игра закрыта. Клиент готов к запуску.'
: 'Игра закрыта, но Wow.exe больше не найден.',
errorMessage: null,
),
),
);
}
Future<void> _onHomeScreenLogoutRequested(
HomeScreenLogoutRequested event,
Emitter<HomeScreenState> emit,
) async {
_pauseRequested = true;
_activeGamePid = null;
await _syncSubscription?.cancel();
_syncSubscription = null;
await _preferencesRepository.clearLauncherSession();
@@ -265,6 +427,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
model: state.model.copyWith(
phase: HomeScreenPhase.idle,
isAuthenticated: false,
isGameRunning: false,
accountUsername: null,
progress: const DownloadProgress.initial(),
statusText: 'Сессия завершена. Войдите снова.',
currentPath: null,
@@ -276,6 +440,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
newsItems: const [],
isLoadingNews: false,
newsErrorMessage: null,
realm: null,
),
),
);
@@ -306,6 +471,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
remoteBuildHash: event.status.remoteBuildHash,
processedFiles: event.status.processedFiles,
totalFiles: event.status.totalFiles,
syncStage: event.status.stage,
),
),
);
@@ -330,6 +496,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
statusText: 'Обновление приостановлено.',
currentPath: null,
errorMessage: null,
syncStage: null,
),
),
);
@@ -344,6 +511,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
statusText: 'Не удалось завершить обновление клиента.',
currentPath: null,
errorMessage: _formatError(event.error),
syncStage: null,
),
),
);
@@ -423,6 +591,9 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
@override
Future<void> close() async {
_pauseRequested = true;
_activeGamePid = null;
_manifestRefreshTimer?.cancel();
_realmRefreshTimer?.cancel();
await _syncSubscription?.cancel();
return super.close();
}
@@ -5,7 +5,17 @@ sealed class HomeScreenEvent {}
final class HomeScreenLoad extends HomeScreenEvent {}
final class HomeScreenSyncRequested extends HomeScreenEvent {}
final class HomeScreenSyncRequested extends HomeScreenEvent {
final ClientManifest? manifest;
HomeScreenSyncRequested({this.manifest});
}
final class HomeScreenManifestRefreshRequested extends HomeScreenEvent {}
final class HomeScreenRealmRefreshRequested extends HomeScreenEvent {}
final class HomeScreenAccountLoadRequested extends HomeScreenEvent {}
final class HomeScreenOutputDirRequested extends HomeScreenEvent {}
@@ -13,6 +23,13 @@ final class HomeScreenPauseRequested extends HomeScreenEvent {}
final class HomeScreenPlayRequested extends HomeScreenEvent {}
final class HomeScreenGameExited extends HomeScreenEvent {
final int pid;
final int? exitCode;
HomeScreenGameExited({required this.pid, required this.exitCode});
}
final class HomeScreenLogoutRequested extends HomeScreenEvent {}
final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
enum HomeScreenPhase {
idle,
@@ -21,6 +23,8 @@ final class HomeScreenModel {
final HomeScreenPhase phase;
final bool isAuthenticated;
final bool hasClientExecutable;
final bool isGameRunning;
final String? accountUsername;
final String statusText;
final String? currentPath;
final String? errorMessage;
@@ -31,6 +35,8 @@ final class HomeScreenModel {
final List<LauncherNewsItem> newsItems;
final bool isLoadingNews;
final String? newsErrorMessage;
final LauncherRealm? realm;
final ClientSyncStage? syncStage;
const HomeScreenModel({
required this.progress,
@@ -38,6 +44,8 @@ final class HomeScreenModel {
required this.phase,
required this.isAuthenticated,
required this.hasClientExecutable,
required this.isGameRunning,
required this.accountUsername,
required this.statusText,
required this.currentPath,
required this.errorMessage,
@@ -48,6 +56,8 @@ final class HomeScreenModel {
required this.newsItems,
required this.isLoadingNews,
required this.newsErrorMessage,
required this.realm,
required this.syncStage,
});
const HomeScreenModel.initial()
@@ -56,6 +66,8 @@ final class HomeScreenModel {
phase = HomeScreenPhase.idle,
isAuthenticated = false,
hasClientExecutable = false,
isGameRunning = false,
accountUsername = null,
statusText = 'Загрузка...',
currentPath = null,
errorMessage = null,
@@ -65,7 +77,9 @@ final class HomeScreenModel {
totalFiles = 0,
newsItems = const <LauncherNewsItem>[],
isLoadingNews = true,
newsErrorMessage = null;
newsErrorMessage = null,
realm = null,
syncStage = null;
bool get isBusy =>
phase == HomeScreenPhase.authenticating ||
@@ -73,7 +87,7 @@ final class HomeScreenModel {
bool get isSyncing => phase == HomeScreenPhase.syncing;
bool get canPlay => hasClientExecutable && !isBusy;
bool get canPlay => hasClientExecutable && !isBusy && !isGameRunning;
HomeScreenModel copyWith({
DownloadProgress? progress,
@@ -81,6 +95,8 @@ final class HomeScreenModel {
HomeScreenPhase? phase,
bool? isAuthenticated,
bool? hasClientExecutable,
bool? isGameRunning,
Object? accountUsername = _sentinel,
String? statusText,
Object? currentPath = _sentinel,
Object? errorMessage = _sentinel,
@@ -91,6 +107,8 @@ final class HomeScreenModel {
List<LauncherNewsItem>? newsItems,
bool? isLoadingNews,
Object? newsErrorMessage = _sentinel,
Object? realm = _sentinel,
Object? syncStage = _sentinel,
}) {
return HomeScreenModel(
progress: progress ?? this.progress,
@@ -100,6 +118,10 @@ final class HomeScreenModel {
phase: phase ?? this.phase,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
hasClientExecutable: hasClientExecutable ?? this.hasClientExecutable,
isGameRunning: isGameRunning ?? this.isGameRunning,
accountUsername: accountUsername == _sentinel
? this.accountUsername
: accountUsername as String?,
statusText: statusText ?? this.statusText,
currentPath: currentPath == _sentinel
? this.currentPath
@@ -120,6 +142,10 @@ final class HomeScreenModel {
newsErrorMessage: newsErrorMessage == _sentinel
? this.newsErrorMessage
: newsErrorMessage as String?,
realm: realm == _sentinel ? this.realm : realm as LauncherRealm?,
syncStage: syncStage == _sentinel
? this.syncStage
: syncStage as ClientSyncStage?,
);
}
}
+3 -105
View File
@@ -1,11 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen_content.dart';
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
@@ -13,7 +10,6 @@ import 'package:moonwell_launcher/features/launcher/domain/entities/client_manif
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:moonwell_launcher/service_container.dart';
import 'package:window_manager/window_manager.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.session, required this.manifest});
@@ -38,113 +34,15 @@ class HomeScreen extends StatelessWidget {
listener: (context, state) async {
await Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (_, __, ___) => const LoginScreen(),
transitionsBuilder: (_, animation, __, child) {
pageBuilder: (_, _, _) => const LoginScreen(),
transitionsBuilder: (_, animation, _, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 250),
),
);
},
child: Scaffold(
body: Stack(
children: [
// Background image
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
// Gradient overlay
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
MWColors.abyss.withAlpha(180),
MWColors.abyss.withAlpha(220),
MWColors.abyss.withAlpha(240),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
// Main content
const Positioned.fill(child: HomeScreenContent()),
// Custom title bar
const Positioned(top: 0, left: 0, right: 0, child: _TitleBar()),
],
),
),
),
);
}
}
class _TitleBar extends StatelessWidget {
const _TitleBar();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GestureDetector(
onPanStart: (_) => windowManager.startDragging(),
child: SizedBox(
height: 36,
child: Row(
children: [
const Spacer(),
_TitleBarButton(
icon: Icons.remove,
onTap: () => windowManager.minimize(),
color: cs.onSurface,
),
_TitleBarButton(
icon: Icons.crop_square,
onTap: () async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
},
color: cs.onSurface,
),
_TitleBarButton(
icon: Icons.close,
onTap: () {
unawaited(windowManager.destroy());
},
color: MWColors.error,
),
],
),
),
);
}
}
class _TitleBarButton extends StatelessWidget {
const _TitleBarButton({
required this.icon,
required this.onTap,
required this.color,
});
final IconData icon;
final VoidCallback onTap;
final Color color;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: SizedBox(
width: 40,
height: 36,
child: Icon(icon, size: 16, color: color.withAlpha(180)),
child: const HomeScreenContent(),
),
);
}
+172 -575
View File
@@ -1,618 +1,215 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
import 'package:moonwell_launcher/app/home_screen/widgets/gilded_progress_bar.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:pixelarticons/pixel.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_news_link.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
class HomeScreenContent extends StatelessWidget {
const HomeScreenContent({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch<HomeScreenBloc>().state;
final model = state.model;
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 36),
child: Column(
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: _NewsPanel(model: model, cs: cs),
),
SizedBox(
width: 320,
child: _RightPanel(model: model, cs: cs),
),
],
),
),
_BottomBar(model: model, cs: cs),
],
),
);
}
}
class _NewsPanel extends StatelessWidget {
const _NewsPanel({required this.model, required this.cs});
final HomeScreenModel model;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 24, top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Image.asset(
'assets/logo.png',
height: 120,
filterQuality: FilterQuality.high,
),
),
),
Text(
'\u041d\u043e\u0432\u043e\u0441\u0442\u0438',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: cs.onSurface),
),
const SizedBox(height: 12),
Expanded(
child: ShaderMask(
shaderCallback: (bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.white, Colors.white.withAlpha(0)],
stops: const [0.0, 0.85, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: _buildNewsBody(context),
),
),
],
final model = context.watch<HomeScreenBloc>().state.model;
return MwLauncherShell(
news: _buildNews(context, model),
syncActions: _buildSyncActions(context, model),
statusBar: _buildStatusBar(model),
account: MwAccountAffordance(
username: model.accountUsername,
onLogout: () =>
context.read<HomeScreenBloc>().add(HomeScreenLogoutRequested()),
),
onSettings: () => _showSettings(context, model),
username: model.accountUsername,
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
onMinimize: windowManager.minimize,
onMaximizeRestore: () => unawaited(_toggleMaximized()),
onClose: () => unawaited(windowManager.destroy()),
);
}
Widget _buildNewsBody(BuildContext context) {
if (model.isLoadingNews) {
return Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2.2, color: cs.primary),
),
);
}
if (model.newsItems.isEmpty && model.newsErrorMessage != null) {
return ListView(
padding: const EdgeInsets.only(right: 16, bottom: 24),
children: [
_NewsStatusCard(
icon: Icons.cloud_off_rounded,
title:
'\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c '
'\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c '
'\u043d\u043e\u0432\u043e\u0441\u0442\u0438',
description: model.newsErrorMessage!,
Widget _buildNews(BuildContext context, HomeScreenModel model) {
final state = model.isLoadingNews
? MwNewsListState.loading
: model.newsItems.isNotEmpty
? MwNewsListState.populated
: model.newsErrorMessage != null
? MwNewsListState.failure
: MwNewsListState.empty;
return MwNewsList(
state: state,
error: model.newsErrorMessage,
items: [
for (final item in model.newsItems)
MwNewsItemData(
title: item.title,
body: item.body,
createdAt: item.createdAt,
imageUrl: item.imageUrl,
onPressed: () => unawaited(_openNews(context, item.id)),
),
],
);
],
);
}
Future<void> _openNews(BuildContext context, int newsId) async {
final newsUri = buildLauncherNewsUri(Config.launcherApiBaseUrl, newsId);
if (newsUri == null) {
_showLinkError(context);
return;
}
if (model.newsItems.isEmpty) {
return ListView(
padding: const EdgeInsets.only(right: 16, bottom: 24),
children: const [
_NewsStatusCard(
icon: Icons.inbox_outlined,
title:
'\u041d\u043e\u0432\u043e\u0441\u0442\u0435\u0439 '
'\u043f\u043e\u043a\u0430 \u043d\u0435\u0442',
description:
'\u041a\u043e\u0433\u0434\u0430 \u0432 API \u043f\u043e\u044f'
'\u0432\u044f\u0442\u0441\u044f \u043f\u0443\u0431\u043b\u0438'
'\u043a\u0430\u0446\u0438\u0438, \u043e\u043d\u0438 '
'\u043f\u043e\u044f\u0432\u044f\u0442\u0441\u044f \u0437\u0434'
'\u0435\u0441\u044c.',
),
],
try {
final opened = await launchUrl(
newsUri,
mode: LaunchMode.externalApplication,
);
if (!opened && context.mounted) {
_showLinkError(context);
}
} catch (_) {
if (context.mounted) {
_showLinkError(context);
}
}
}
return ListView.separated(
padding: const EdgeInsets.only(right: 16, bottom: 24),
itemCount: model.newsItems.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
return _NewsCard(item: model.newsItems[index]);
void _showLinkError(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось открыть новость в браузере.')),
);
}
Widget _buildSyncActions(BuildContext context, HomeScreenModel model) {
final presentation = _syncPresentation(model);
final progress = model.progress.total > 0
? (model.progress.downloaded / model.progress.total).clamp(0.0, 1.0)
: null;
return MwSyncActionArea(
state: presentation,
status: model.statusText,
currentPath: model.currentPath,
error: model.errorMessage,
progress: progress,
realmName: model.realm?.name,
realmOnline: model.realm?.online,
realmGameBuild: model.realm?.gameBuild,
onPrimary: () {
final bloc = context.read<HomeScreenBloc>();
if (model.phase == HomeScreenPhase.readyToPlay && model.canPlay) {
bloc.add(HomeScreenPlayRequested());
} else {
bloc.add(HomeScreenSyncRequested());
}
},
);
}
}
class _NewsStatusCard extends StatelessWidget {
const _NewsStatusCard({
required this.icon,
required this.title,
required this.description,
});
final IconData icon;
final String title;
final String description;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surface.withAlpha(140),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.outline.withAlpha(100)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: cs.tertiary),
const SizedBox(height: 12),
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: cs.tertiary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurface.withAlpha(200),
height: 1.4,
),
),
],
),
);
}
}
class _NewsCard extends StatelessWidget {
const _NewsCard({required this.item});
final LauncherNewsItem item;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surface.withAlpha(140),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.outline.withAlpha(100)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: cs.tertiary,
fontWeight: FontWeight.w700,
),
),
if (item.createdAt != null) ...[
const SizedBox(height: 6),
Text(
_formatCreatedAt(item.createdAt!),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
if (item.imageUrl != null) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 16 / 7,
child: Image.network(
item.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: cs.surface.withAlpha(120),
alignment: Alignment.center,
child: Icon(
Icons.broken_image_outlined,
color: cs.onSurfaceVariant,
),
),
),
),
),
],
const SizedBox(height: 8),
Text(
item.body,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurface.withAlpha(200),
height: 1.4,
),
),
],
),
onPause: model.isSyncing
? () => context.read<HomeScreenBloc>().add(HomeScreenPauseRequested())
: null,
);
}
String _formatCreatedAt(DateTime value) {
final localValue = value.toLocal();
final day = localValue.day.toString().padLeft(2, '0');
final month = localValue.month.toString().padLeft(2, '0');
final year = localValue.year.toString();
return '$day.$month.$year';
}
}
class _RightPanel extends StatelessWidget {
const _RightPanel({required this.model, required this.cs});
final HomeScreenModel model;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
return Container(
margin: const EdgeInsets.only(right: 20, top: 8, bottom: 12),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: MWColors.abyss.withAlpha(200),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline.withAlpha(80)),
boxShadow: deco.cardGlow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => context.read<HomeScreenBloc>().add(
HomeScreenLogoutRequested(),
),
icon: const Icon(Icons.logout_rounded, size: 16),
label: const Text('\u0412\u044b\u0439\u0442\u0438'),
style: TextButton.styleFrom(
foregroundColor: cs.onSurfaceVariant,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
const SizedBox(height: 12),
const Spacer(),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => context.read<HomeScreenBloc>().add(
HomeScreenOutputDirRequested(),
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: cs.surface.withAlpha(100),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(80)),
),
child: Row(
children: [
Icon(Pixel.folder, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Text(
model.outputPath?.toFilePath() ??
'\u0412\u044b\u0431\u0440\u0430\u0442\u044c '
'\u043f\u0430\u043f\u043a\u0443',
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
),
Icon(
Icons.edit,
size: 14,
color: cs.onSurfaceVariant.withAlpha(120),
),
],
),
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: cs.surface.withAlpha(80),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(60)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
model.statusText,
style: TextStyle(
color: cs.onSurface.withAlpha(180),
fontSize: 13,
),
),
if (model.currentPath != null) ...[
const SizedBox(height: 6),
Text(
model.currentPath!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
],
if (model.totalFiles > 0) ...[
const SizedBox(height: 6),
Text(
'\u0424\u0430\u0439\u043b\u044b: '
'${model.processedFiles}/${model.totalFiles}',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
],
if (model.errorMessage != null) ...[
const SizedBox(height: 8),
Text(
model.errorMessage!,
style: TextStyle(color: cs.error, fontSize: 12),
),
],
],
),
),
const Spacer(),
if (model.remoteBuildHash != null || model.localBuildHash != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Build: ${_shortHash(model.localBuildHash)} / '
'${_shortHash(model.remoteBuildHash)}',
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10),
),
),
],
),
);
}
String _shortHash(String? hash) {
if (hash == null || hash.isEmpty) {
return '-';
MwSyncPresentation _syncPresentation(HomeScreenModel model) {
if (model.phase == HomeScreenPhase.paused) {
return MwSyncPresentation.paused;
}
if (hash.length <= 12) {
return hash;
if (model.phase == HomeScreenPhase.failure) {
return MwSyncPresentation.failure;
}
return '${hash.substring(0, 8)}...${hash.substring(hash.length - 4)}';
if (model.phase == HomeScreenPhase.readyToPlay) {
return model.isGameRunning
? MwSyncPresentation.launched
: MwSyncPresentation.ready;
}
if (model.phase != HomeScreenPhase.syncing) {
return MwSyncPresentation.install;
}
return switch (model.syncStage) {
ClientSyncStage.downloadingFiles => MwSyncPresentation.downloading,
ClientSyncStage.verifyingInstallation => MwSyncPresentation.verifying,
ClientSyncStage.completed => MwSyncPresentation.ready,
_ => MwSyncPresentation.scanning,
};
}
}
class _BottomBar extends StatelessWidget {
const _BottomBar({required this.model, required this.cs});
Widget _buildStatusBar(HomeScreenModel model) {
return MwLauncherStatusBar(
status: model.outputPath?.toFilePath() ?? 'Папка установки не выбрана',
fileCount: model.totalFiles > 0
? '${model.processedFiles}/${model.totalFiles}'
: null,
speed: model.progress.speed > 0
? _formatSpeed(model.progress.speed)
: null,
eta: model.progress.eta > Duration.zero
? _formatDuration(model.progress.eta)
: null,
buildHash: _shortHash(model.localBuildHash ?? model.remoteBuildHash),
);
}
final HomeScreenModel model;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
final progressValue = model.progress.total == 0
? 0.0
: model.progress.downloaded / model.progress.total;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [MWColors.abyss.withAlpha(0), MWColors.abyss.withAlpha(230)],
Future<void> _showSettings(
BuildContext context,
HomeScreenModel model,
) async {
final bloc = context.read<HomeScreenBloc>();
await showDialog<void>(
context: context,
builder: (dialogContext) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460, maxHeight: 620),
child: SingleChildScrollView(
child: MwSettingsPanel(
installationPath: model.outputPath?.toFilePath(),
syncActive: model.isSyncing || model.isGameRunning,
onChooseDirectory: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenOutputDirRequested());
},
onVerify: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenSyncRequested());
},
),
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (model.isSyncing) ...[
Row(
children: [
Expanded(child: GildedProgressBar(value: progressValue)),
const SizedBox(width: 12),
Text(
'${(progressValue * 100).clamp(0, 100).toStringAsFixed(1)}%',
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
Text(
_formatSpeed(model.progress.speed),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
],
),
const SizedBox(height: 8),
],
Row(
children: [
SizedBox(
height: 44,
child: ElevatedButton.icon(
icon: Icon(_resolvePrimaryIcon(model), size: 20),
label: Text(_resolvePrimaryLabel(model)),
onPressed: model.isBusy
? null
: () => _onPrimaryAction(context, model),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 28),
),
),
),
if (model.canPlay || model.isSyncing) ...[
const SizedBox(width: 8),
SizedBox(
height: 44,
child: OutlinedButton.icon(
icon: Icon(_resolveSecondaryIcon(model), size: 18),
label: Text(_resolveSecondaryLabel(model)),
onPressed: () => _onSecondaryAction(context, model),
),
),
],
const SizedBox(width: 16),
Expanded(
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: cs.surface.withAlpha(100),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(60)),
),
alignment: Alignment.centerLeft,
child: Text(
model.currentPath ?? model.statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface.withAlpha(160),
fontSize: 12,
),
),
),
),
],
),
],
),
);
}
void _onPrimaryAction(BuildContext context, HomeScreenModel model) {
final bloc = context.read<HomeScreenBloc>();
if (model.canPlay) {
bloc.add(HomeScreenPlayRequested());
return;
Future<void> _toggleMaximized() async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
bloc.add(HomeScreenSyncRequested());
}
void _onSecondaryAction(BuildContext context, HomeScreenModel model) {
final bloc = context.read<HomeScreenBloc>();
if (model.isSyncing) {
bloc.add(HomeScreenPauseRequested());
return;
}
if (model.canPlay) {
bloc.add(HomeScreenSyncRequested());
return;
}
bloc.add(HomeScreenOutputDirRequested());
}
String _resolvePrimaryLabel(HomeScreenModel model) {
if (model.isSyncing) {
return '\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435...';
}
if (model.canPlay) {
return '\u0418\u0433\u0440\u0430\u0442\u044c';
}
return model.hasClientExecutable
? '\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u044c'
: '\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c';
}
IconData _resolvePrimaryIcon(HomeScreenModel model) {
if (model.canPlay) {
return Icons.play_arrow_rounded;
}
return Icons.download_rounded;
}
String _resolveSecondaryLabel(HomeScreenModel model) {
if (model.isSyncing) {
return '\u041f\u0430\u0443\u0437\u0430';
}
if (model.canPlay) {
return '\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c';
}
return '\u041f\u0430\u043f\u043a\u0430';
}
IconData _resolveSecondaryIcon(HomeScreenModel model) {
if (model.isSyncing) {
return Pixel.pause;
}
if (model.canPlay) {
return Icons.refresh_rounded;
}
return Pixel.folder;
}
String _formatSpeed(int bytesPerSecond) {
if (bytesPerSecond <= 0) {
return '-';
const megabyte = 1024 * 1024;
if (bytesPerSecond >= megabyte) {
return '${(bytesPerSecond / megabyte).toStringAsFixed(1)} MB/s';
}
return '${(bytesPerSecond / 1024).toStringAsFixed(0)} KB/s';
}
if (bytesPerSecond >= 1024 * 1024) {
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(2)} MB/s';
}
String _formatDuration(Duration duration) {
final hours = duration.inHours.toString().padLeft(2, '0');
final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0');
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds';
}
if (bytesPerSecond >= 1024) {
return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
}
return '$bytesPerSecond B/s';
String? _shortHash(String? hash) {
if (hash == null || hash.isEmpty) return null;
return hash.length <= 8 ? hash : hash.substring(0, 8);
}
}
@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
class GildedProgressBar extends StatelessWidget {
const GildedProgressBar({super.key, required this.value});
final double value;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
return Container(
height: 20,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: cs.outline),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Subtle top highlight
Positioned(
top: 0.0,
height: 6.0,
left: 0.0,
right: 0.0,
child: ColoredBox(color: Colors.white.withAlpha(10)),
),
Positioned.fill(
child: FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
alignment: Alignment.centerLeft,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: (deco.goldBevel as LinearGradient),
),
),
),
),
],
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/widgets.dart';
class LauncherVersionScope extends InheritedWidget {
const LauncherVersionScope({
super.key,
required this.version,
required super.child,
});
final String version;
static String of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<LauncherVersionScope>()
?.version ??
'dev';
}
@override
bool updateShouldNotify(LauncherVersionScope oldWidget) {
return version != oldWidget.version;
}
}
+127 -180
View File
@@ -1,8 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_authentication.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:moonwell_launcher/service_container.dart';
@@ -17,17 +18,30 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _passwordConfirmationController = TextEditingController();
final _inviteCodeController = TextEditingController();
MwAuthenticationMode _mode = MwAuthenticationMode.login;
bool _termsAccepted = false;
bool _isLoading = false;
String? _error;
String? _success;
@override
void dispose() {
_usernameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_passwordConfirmationController.dispose();
_inviteCodeController.dispose();
super.dispose();
}
Future<void> _submit() {
return _mode == MwAuthenticationMode.login ? _login() : _register();
}
Future<void> _login() async {
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
@@ -40,6 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() {
_isLoading = true;
_error = null;
_success = null;
});
try {
@@ -53,9 +68,9 @@ class _LoginScreenState extends State<LoginScreen> {
await Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (_, __, ___) =>
pageBuilder: (_, _, _) =>
HomeScreen(session: session, manifest: manifest),
transitionsBuilder: (_, animation, __, child) {
transitionsBuilder: (_, animation, _, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 400),
@@ -70,6 +85,89 @@ class _LoginScreenState extends State<LoginScreen> {
}
}
Future<void> _register() async {
final username = _usernameController.text.trim();
final email = _emailController.text.trim();
final password = _passwordController.text;
final passwordConfirmation = _passwordConfirmationController.text;
final validationError = _validateRegistration(
username: username,
email: email,
password: password,
passwordConfirmation: passwordConfirmation,
);
if (validationError != null) {
setState(() => _error = validationError);
return;
}
setState(() {
_isLoading = true;
_error = null;
_success = null;
});
try {
final message = await getIt<LauncherApiClient>().register(
username: username,
email: email,
password: password,
passwordConfirmation: passwordConfirmation,
terms: _termsAccepted,
inviteCode: _inviteCodeController.text,
);
if (!mounted) return;
setState(() {
_mode = MwAuthenticationMode.login;
_isLoading = false;
_termsAccepted = false;
_passwordConfirmationController.clear();
_inviteCodeController.clear();
_success = message;
});
} catch (error) {
if (!mounted) return;
setState(() {
_isLoading = false;
_error = _formatError(error);
});
}
}
String? _validateRegistration({
required String username,
required String email,
required String password,
required String passwordConfirmation,
}) {
if (!RegExp(r'^[A-Za-z0-9]{3,32}$').hasMatch(username)) {
return 'Логин должен содержать от 3 до 32 латинских букв или цифр.';
}
if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) {
return 'Введите корректный email.';
}
if (password.length < 8 || password.length > 32) {
return 'Пароль должен содержать от 8 до 32 символов.';
}
if (password != passwordConfirmation) {
return 'Пароли не совпадают.';
}
if (!_termsAccepted) {
return 'Примите правила сервера и пользовательское соглашение.';
}
return null;
}
void _setMode(MwAuthenticationMode mode) {
setState(() {
_mode = mode;
_error = null;
_success = null;
});
}
String _formatError(Object error) {
final raw = error.toString();
if (raw.startsWith('Exception: ')) return raw.substring(11);
@@ -78,186 +176,35 @@ class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
body: Stack(
children: [
// Background
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.0,
colors: [
MWColors.abyss.withAlpha(200),
MWColors.abyss.withAlpha(240),
],
),
),
),
),
// Title bar
Positioned(top: 0, left: 0, right: 0, child: _TitleBar(cs: cs)),
// Login form — centered
Center(
child: SizedBox(
width: 340,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo
Image.asset(
'assets/logo.png',
height: 160,
filterQuality: FilterQuality.high,
),
const SizedBox(height: 32),
// Card
Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: MWColors.abyss.withAlpha(210),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline.withAlpha(80)),
boxShadow: [
BoxShadow(
color: MWColors.primary.withAlpha(30),
blurRadius: 40,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _usernameController,
enabled: !_isLoading,
decoration: const InputDecoration(
labelText: 'Логин',
prefixIcon: Icon(Icons.person_outline),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
enabled: !_isLoading,
obscureText: true,
onSubmitted: (_) => _login(),
decoration: const InputDecoration(
labelText: 'Пароль',
prefixIcon: Icon(Icons.lock_outline),
),
textInputAction: TextInputAction.done,
),
const SizedBox(height: 24),
SizedBox(
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : _login,
child: _isLoading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Text('Войти'),
),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: cs.error, fontSize: 13),
),
],
],
),
),
],
),
),
),
],
return MwLoginShell(
form: MwAuthenticationForm(
usernameController: _usernameController,
emailController: _emailController,
passwordController: _passwordController,
passwordConfirmationController: _passwordConfirmationController,
inviteCodeController: _inviteCodeController,
mode: _mode,
onModeChanged: _setMode,
termsAccepted: _termsAccepted,
onTermsChanged: (value) => setState(() => _termsAccepted = value),
loading: _isLoading,
error: _error,
success: _success,
onSubmit: _submit,
),
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
onMinimize: windowManager.minimize,
onMaximizeRestore: () => unawaited(_toggleMaximized()),
onClose: () => unawaited(windowManager.destroy()),
);
}
}
class _TitleBar extends StatelessWidget {
const _TitleBar({required this.cs});
final ColorScheme cs;
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: (_) => windowManager.startDragging(),
child: SizedBox(
height: 36,
child: Row(
children: [
const Spacer(),
_TitleBarButton(
icon: Icons.remove,
onTap: () => windowManager.minimize(),
color: cs.onSurface,
),
_TitleBarButton(
icon: Icons.crop_square,
onTap: () async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
},
color: cs.onSurface,
),
_TitleBarButton(
icon: Icons.close,
onTap: () {
unawaited(windowManager.destroy());
},
color: MWColors.error,
),
],
),
),
);
}
}
class _TitleBarButton extends StatelessWidget {
const _TitleBarButton({
required this.icon,
required this.onTap,
required this.color,
});
final IconData icon;
final VoidCallback onTap;
final Color color;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: SizedBox(
width: 40,
height: 36,
child: Icon(icon, size: 16, color: color.withAlpha(180)),
),
);
Future<void> _toggleMaximized() async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
}
}
+69 -51
View File
@@ -1,25 +1,74 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
import 'package:moonwell_launcher/features/launcher/application/restore_launcher_session_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:moonwell_launcher/service_container.dart';
class MoonWellApp extends StatelessWidget {
const MoonWellApp({super.key});
class MoonWellApp extends StatefulWidget {
const MoonWellApp({super.key, required this.launcherVersion});
final String launcherVersion;
@override
State<MoonWellApp> createState() => _MoonWellAppState();
}
class _MoonWellAppState extends State<MoonWellApp> {
late final LauncherThemeController _themeController;
@override
void initState() {
super.initState();
_themeController = LauncherThemeController(getIt<PreferencesRepository>());
_themeController.load();
}
@override
void dispose() {
_themeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'MoonWell',
home: const _LauncherBootstrapScreen(),
theme: moonWellTheme(),
return AnimatedBuilder(
animation: _themeController,
builder: (context, _) => LauncherVersionScope(
version: widget.launcherVersion,
child: LauncherThemeScope(
controller: _themeController,
child: MaterialApp(
title: 'MoonWell',
debugShowCheckedModeBanner: false,
home: const _LauncherBootstrapScreen(),
theme: MoonWellTheme.create(_themeController.variant),
),
),
),
);
}
}
class LauncherThemeScope extends InheritedNotifier<LauncherThemeController> {
const LauncherThemeScope({
super.key,
required LauncherThemeController controller,
required super.child,
}) : super(notifier: controller);
static LauncherThemeController of(BuildContext context) {
final scope = context
.dependOnInheritedWidgetOfExactType<LauncherThemeScope>();
assert(scope != null, 'LauncherThemeScope is missing');
return scope!.notifier!;
}
}
class _LauncherBootstrapScreen extends StatefulWidget {
const _LauncherBootstrapScreen();
@@ -48,7 +97,6 @@ class _LauncherBootstrapScreenState extends State<_LauncherBootstrapScreen> {
if (snapshot.connectionState != ConnectionState.done) {
return const _LauncherBootstrapLoadingScreen();
}
final result =
snapshot.data ??
const RestoreLauncherSessionResult.unauthenticated();
@@ -58,7 +106,6 @@ class _LauncherBootstrapScreenState extends State<_LauncherBootstrapScreen> {
manifest: result.manifest!,
);
}
return const LoginScreen();
},
);
@@ -70,50 +117,21 @@ class _LauncherBootstrapLoadingScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.0,
colors: [
MWColors.abyss.withAlpha(200),
MWColors.abyss.withAlpha(240),
],
),
return const Scaffold(
body: MwArtworkBackground(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MwBrandMark(width: 220),
SizedBox(height: 24),
SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/logo.png',
height: 160,
filterQuality: FilterQuality.high,
),
const SizedBox(height: 24),
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: cs.primary,
),
),
],
),
),
],
),
),
);
}
+309
View File
@@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
export 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
@immutable
class MoonWellDesignTokens extends ThemeExtension<MoonWellDesignTokens> {
const MoonWellDesignTokens({
required this.background,
required this.surfaceLow,
required this.surface,
required this.surfaceHigh,
required this.accent,
required this.accentMuted,
required this.success,
required this.warning,
required this.border,
required this.focusRing,
required this.spacing,
required this.radiusSmall,
required this.radiusMedium,
required this.radiusLarge,
required this.panelShadow,
required this.motionFast,
required this.motionStandard,
required this.brandFontFamily,
required this.monospaceFontFamily,
});
final Color background;
final Color surfaceLow;
final Color surface;
final Color surfaceHigh;
final Color accent;
final Color accentMuted;
final Color success;
final Color warning;
final BorderSide border;
final Color focusRing;
final double spacing;
final double radiusSmall;
final double radiusMedium;
final double radiusLarge;
final List<BoxShadow> panelShadow;
final Duration motionFast;
final Duration motionStandard;
final String brandFontFamily;
final String monospaceFontFamily;
static MoonWellDesignTokens of(BuildContext context) =>
Theme.of(context).extension<MoonWellDesignTokens>()!;
@override
MoonWellDesignTokens copyWith({
Color? background,
Color? surfaceLow,
Color? surface,
Color? surfaceHigh,
Color? accent,
Color? accentMuted,
Color? success,
Color? warning,
BorderSide? border,
Color? focusRing,
double? spacing,
double? radiusSmall,
double? radiusMedium,
double? radiusLarge,
List<BoxShadow>? panelShadow,
Duration? motionFast,
Duration? motionStandard,
String? brandFontFamily,
String? monospaceFontFamily,
}) {
return MoonWellDesignTokens(
background: background ?? this.background,
surfaceLow: surfaceLow ?? this.surfaceLow,
surface: surface ?? this.surface,
surfaceHigh: surfaceHigh ?? this.surfaceHigh,
accent: accent ?? this.accent,
accentMuted: accentMuted ?? this.accentMuted,
success: success ?? this.success,
warning: warning ?? this.warning,
border: border ?? this.border,
focusRing: focusRing ?? this.focusRing,
spacing: spacing ?? this.spacing,
radiusSmall: radiusSmall ?? this.radiusSmall,
radiusMedium: radiusMedium ?? this.radiusMedium,
radiusLarge: radiusLarge ?? this.radiusLarge,
panelShadow: panelShadow ?? this.panelShadow,
motionFast: motionFast ?? this.motionFast,
motionStandard: motionStandard ?? this.motionStandard,
brandFontFamily: brandFontFamily ?? this.brandFontFamily,
monospaceFontFamily: monospaceFontFamily ?? this.monospaceFontFamily,
);
}
@override
MoonWellDesignTokens lerp(covariant MoonWellDesignTokens? other, double t) {
if (other == null) return this;
return MoonWellDesignTokens(
background: Color.lerp(background, other.background, t)!,
surfaceLow: Color.lerp(surfaceLow, other.surfaceLow, t)!,
surface: Color.lerp(surface, other.surface, t)!,
surfaceHigh: Color.lerp(surfaceHigh, other.surfaceHigh, t)!,
accent: Color.lerp(accent, other.accent, t)!,
accentMuted: Color.lerp(accentMuted, other.accentMuted, t)!,
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
border: BorderSide.lerp(border, other.border, t),
focusRing: Color.lerp(focusRing, other.focusRing, t)!,
spacing: lerpDouble(spacing, other.spacing, t),
radiusSmall: lerpDouble(radiusSmall, other.radiusSmall, t),
radiusMedium: lerpDouble(radiusMedium, other.radiusMedium, t),
radiusLarge: lerpDouble(radiusLarge, other.radiusLarge, t),
panelShadow: t < .5 ? panelShadow : other.panelShadow,
motionFast: t < .5 ? motionFast : other.motionFast,
motionStandard: t < .5 ? motionStandard : other.motionStandard,
brandFontFamily: t < .5 ? brandFontFamily : other.brandFontFamily,
monospaceFontFamily: t < .5
? monospaceFontFamily
: other.monospaceFontFamily,
);
}
static double lerpDouble(double a, double b, double t) => a + (b - a) * t;
}
abstract final class MoonWellTheme {
static ThemeData create(MoonWellThemeVariant variant, {String? fontPackage}) {
final isForest = variant == MoonWellThemeVariant.forest;
const background = Color(0xFF070A18);
const surfaceLow = Color(0xFF0A0D1F);
const surface = Color(0xFF10142A);
const surfaceHigh = Color(0xFF1A1F3A);
final accent = isForest ? const Color(0xFF7FB8D4) : const Color(0xFF8A5CC8);
const accentMuted = Color(0xFF5B3A8A);
const outline = Color(0xFF34455E);
const foreground = Color(0xFFECE4D0);
const error = Color(0xFFFF8A86);
const success = Color(0xFF70D7A5);
const warning = Color(0xFFFFC865);
final scheme = ColorScheme.dark(
primary: accent,
onPrimary: background,
secondary: accentMuted,
onSecondary: foreground,
tertiary: const Color(0xFF91C7D0),
onTertiary: background,
error: error,
onError: background,
surface: surface,
onSurface: foreground,
outline: outline,
shadow: Colors.black,
scrim: Colors.black87,
);
String font(String family) =>
fontPackage == null ? family : 'packages/$fontPackage/$family';
final base = ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: background,
canvasColor: background,
fontFamily: font('Inter'),
focusColor: accent.withAlpha(60),
visualDensity: VisualDensity.standard,
);
final radius = BorderRadius.circular(10);
final textTheme = base.textTheme
.apply(bodyColor: foreground, displayColor: foreground)
.copyWith(
displayLarge: base.textTheme.displayLarge?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w600,
),
displayMedium: base.textTheme.displayMedium?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w600,
),
headlineLarge: base.textTheme.headlineLarge?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w600,
),
headlineMedium: base.textTheme.headlineMedium?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w600,
),
titleLarge: base.textTheme.titleLarge?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w700,
),
labelLarge: base.textTheme.labelLarge?.copyWith(
fontFamily: font('Inter'),
fontWeight: FontWeight.w700,
),
);
return base.copyWith(
textTheme: textTheme,
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceHigh,
hintStyle: TextStyle(color: foreground.withAlpha(105)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
enabledBorder: OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide(color: outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide(color: accent, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: radius,
borderSide: const BorderSide(color: error),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(44, 44),
backgroundColor: accent,
foregroundColor: background,
disabledBackgroundColor: outline.withAlpha(90),
disabledForegroundColor: foreground.withAlpha(120),
shape: RoundedRectangleBorder(borderRadius: radius),
textStyle: TextStyle(
fontFamily: font('Inter'),
fontWeight: FontWeight.w700,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(44, 44),
foregroundColor: foreground,
side: BorderSide(color: outline),
shape: RoundedRectangleBorder(borderRadius: radius),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(44, 44),
foregroundColor: accent,
),
),
checkboxTheme: CheckboxThemeData(
side: BorderSide(color: outline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
extensions: [
MoonWellDesignTokens(
background: background,
surfaceLow: surfaceLow,
surface: surface,
surfaceHigh: surfaceHigh,
accent: accent,
accentMuted: accentMuted,
success: success,
warning: warning,
border: BorderSide(color: outline.withAlpha(190)),
focusRing: accent,
spacing: 8,
radiusSmall: 6,
radiusMedium: 10,
radiusLarge: 16,
panelShadow: const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 24,
offset: Offset(0, 12),
),
],
motionFast: const Duration(milliseconds: 120),
motionStandard: const Duration(milliseconds: 220),
brandFontFamily: font('Cinzel'),
monospaceFontFamily: font('JetBrains Mono'),
),
],
);
}
}
class LauncherThemeController extends ChangeNotifier {
LauncherThemeController(this._preferencesRepository);
final PreferencesRepository _preferencesRepository;
MoonWellThemeVariant _variant = MoonWellThemeVariant.forest;
MoonWellThemeVariant get variant => _variant;
Future<void> load() async {
_variant = await _preferencesRepository.getThemeVariant();
notifyListeners();
}
Future<void> setVariant(MoonWellThemeVariant variant) async {
if (_variant == variant) return;
_variant = variant;
notifyListeners();
await _preferencesRepository.setThemeVariant(variant);
}
}
-22
View File
@@ -1,22 +0,0 @@
part of 'mw_theme.dart';
/// Core palette
class MWColors {
// Surface & background
static const Color abyss = Color(0xFF090F2B); // page bg
static const Color deepNavy = Color(0xFF111840); // surfaces
static const Color stormNavy = Color(0xFF1A2759); // elevated surfaces
// Primary & secondary
static const Color primary = Color(0xFF5460A2);
static const Color secondary = Color(0xFF5F6BD2);
static const Color tertiary = Color(0xFF6494EB);
// Lines & states
static const Color outline = Color(0xFF4B4E6E);
// Semantic
static const Color success = Color(0xFF3DDC97);
static const Color warning = Color(0xFFF0B429);
static const Color error = Color(0xFFD86A8A);
}
-40
View File
@@ -1,40 +0,0 @@
part of 'mw_theme.dart';
class MoonWellDecorations extends ThemeExtension<MoonWellDecorations> {
final Gradient goldBevel; // for gilded headers/buttons
final Shadow textGlow; // subtle moonlight glow
final List<BoxShadow> cardGlow;
const MoonWellDecorations({
required this.goldBevel,
required this.textGlow,
required this.cardGlow,
});
@override
MoonWellDecorations copyWith({
Gradient? goldBevel,
Shadow? textGlow,
List<BoxShadow>? cardGlow,
}) => MoonWellDecorations(
goldBevel: goldBevel ?? this.goldBevel,
textGlow: textGlow ?? this.textGlow,
cardGlow: cardGlow ?? this.cardGlow,
);
@override
ThemeExtension<MoonWellDecorations> lerp(
ThemeExtension<MoonWellDecorations>? other,
double t,
) {
if (other is! MoonWellDecorations) return this;
return MoonWellDecorations(
goldBevel: Gradient.lerp(goldBevel, other.goldBevel, t)!,
textGlow: Shadow.lerp(textGlow, other.textGlow, t)!,
cardGlow: [
for (int i = 0; i < cardGlow.length; i++)
BoxShadow.lerp(cardGlow[i], other.cardGlow[i], t)!,
],
);
}
}
-220
View File
@@ -1,220 +0,0 @@
import 'package:flutter/material.dart';
part 'mw_colors.dart';
part 'mw_decorations.dart';
ThemeData moonWellTheme() {
const cs = ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF5460A2),
onPrimary: Color(0xFFDDE0FB),
secondary: Color(0xFF5F6BD2),
onSecondary: Color(0xFFDDE0FB),
tertiary: Color(0xFF6494EB),
onTertiary: Color(0xFF090F2B),
error: Color(0xFFD86A8A),
onError: Color(0xFF090F2B),
surface: Color(0xFF1A2759),
onSurface: Color(0xFFDDE0FB),
outline: Color(0xFF4B4E6E),
shadow: Color(0xFF000000),
scrim: Color(0xCC000000),
);
final base = ThemeData(
useMaterial3: true,
colorScheme: cs,
scaffoldBackgroundColor: cs.surface,
canvasColor: cs.surface,
);
final text = base.textTheme
.apply(
fontFamily: 'Cinzel',
bodyColor: cs.onSurface,
displayColor: cs.onSurface,
)
.copyWith(
displayLarge: base.textTheme.displayLarge?.copyWith(
letterSpacing: 0.5,
fontWeight: FontWeight.w700,
shadows: const [Shadow(blurRadius: 10, color: Color(0x336BA3FF))],
),
headlineMedium: base.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
titleLarge: base.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
);
return base.copyWith(
textTheme: text,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: cs.onSurface,
centerTitle: true,
titleTextStyle: text.titleLarge,
toolbarHeight: 64,
),
cardTheme: CardThemeData(
color: cs.surface.withAlpha(50),
elevation: 0,
margin: const EdgeInsets.all(12),
shape: RoundedRectangleBorder(
side: BorderSide(color: MWColors.outline.withAlpha(150)),
borderRadius: BorderRadius.circular(20),
),
shadowColor: MWColors.tertiary.withAlpha(63),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 18, vertical: 14),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
textStyle: WidgetStatePropertyAll(
TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Cinzel'),
),
elevation: const WidgetStatePropertyAll(6),
shadowColor: WidgetStatePropertyAll(MWColors.tertiary.withAlpha(89)),
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return MWColors.primary.withAlpha(115);
}
return cs.primary;
}),
foregroundColor: const WidgetStatePropertyAll(Color(0xFFDDE0FB)),
),
),
textButtonTheme: TextButtonThemeData(
style: ButtonStyle(
foregroundColor: WidgetStatePropertyAll(cs.tertiary),
overlayColor: WidgetStatePropertyAll(cs.tertiary.withAlpha(10)),
),
),
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(cs.tertiaryContainer),
foregroundColor: WidgetStatePropertyAll(cs.onTertiaryContainer),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
side: WidgetStatePropertyAll(
BorderSide(color: MWColors.outline.withAlpha(230)),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return cs.primary.withAlpha(100);
}
return cs.primary;
}),
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return MWColors.stormNavy.withAlpha(100);
}
return MWColors.stormNavy;
}),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: cs.surfaceContainerHighest,
hintStyle: TextStyle(color: cs.onSurfaceVariant.withAlpha(179)),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: MWColors.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: cs.primary, width: 1.6),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: cs.error),
),
prefixIconColor: cs.onSurfaceVariant,
suffixIconColor: cs.onSurfaceVariant,
),
chipTheme: base.chipTheme.copyWith(
backgroundColor: cs.surfaceContainerHighest,
side: BorderSide(color: MWColors.outline),
selectedColor: cs.primaryContainer,
labelStyle: TextStyle(color: cs.onSurface),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
sliderTheme: base.sliderTheme.copyWith(
activeTrackColor: cs.primary,
inactiveTrackColor: cs.primary.withAlpha(63),
thumbColor: cs.primary,
),
dividerTheme: DividerThemeData(
color: MWColors.outline,
thickness: 1,
space: 24,
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: cs.surface,
selectedItemColor: cs.primary,
unselectedItemColor: cs.onSurface.withAlpha(153),
elevation: 8,
type: BottomNavigationBarType.fixed,
),
extensions: <ThemeExtension<dynamic>>[
const MoonWellDecorations(
goldBevel: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Color(0xFF7A85CC), // highlight
Color(0xFF5460A2), // body
Color(0xFF3E4880), // edge
],
stops: [0.0, 0.55, 1.0],
),
textGlow: Shadow(
color: Color(0x446494EB),
blurRadius: 14,
offset: Offset(0, 0),
),
cardGlow: [
BoxShadow(
color: Color(0x33143666), // cool rim light
blurRadius: 28,
spreadRadius: 2,
offset: Offset(0, 8),
),
],
),
],
);
}
+4
View File
@@ -2,12 +2,16 @@ final class Config {
static const launcherApiBaseUrl = String.fromEnvironment(
'MOONWELL_API_BASE_URL',
);
static const launcherAppcastUrl = String.fromEnvironment(
'MOONWELL_APPCAST_URL',
);
static const launcherLogFileName = 'launcher.log';
static const gameExecutableName = 'Wow.exe';
static const cacheDirectoryName = 'Cache';
static const launcherMetadataDirectoryName = '.moonwell_launcher';
static const hashCacheFileName = 'hash_cache.json';
static const ignoredVerificationDirectories = <String>{
'addons',
'cache',
'errors',
'logs',
+11
View File
@@ -0,0 +1,11 @@
enum MoonWellThemeVariant {
forest,
temple;
static MoonWellThemeVariant fromStoredValue(String? value) {
return MoonWellThemeVariant.values.firstWhere(
(variant) => variant.name == value,
orElse: () => MoonWellThemeVariant.forest,
);
}
}
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/core/use_case.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
@@ -95,6 +96,7 @@ class ClientSyncUseCase
final localSnapshot = await _installationService.scanInstallation(
input.request.installationDir,
isCancelled: input.isCancelled,
manifest: manifest,
onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit(
controller,
@@ -149,7 +151,7 @@ class ClientSyncUseCase
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is up to date.',
message: 'Клиент обновлён.',
currentPath: null,
localBuildHash: localSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
@@ -185,6 +187,7 @@ class ClientSyncUseCase
final verifiedSnapshot = await _installationService.scanInstallation(
input.request.installationDir,
isCancelled: input.isCancelled,
manifest: manifest,
onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit(
controller,
@@ -225,7 +228,7 @@ class ClientSyncUseCase
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is ready to play.',
message: 'Клиент готов к игре.',
currentPath: null,
localBuildHash: verifiedSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
@@ -279,7 +282,13 @@ class ClientSyncUseCase
final remoteFiles = manifest.filesByPath;
return snapshot.files
.where((localFile) => !remoteFiles.containsKey(localFile.path))
.where((localFile) {
if (remoteFiles.containsKey(localFile.path)) {
return false;
}
return !_isIgnored(localFile.path);
})
.map((file) => file.path)
.toList();
}
@@ -454,14 +463,21 @@ class ClientSyncUseCase
final mismatchedFiles = manifest.files.where((remoteFile) {
final localFile = localFilesByPath[remoteFile.path];
return localFile == null ||
final isMismatched =
localFile == null ||
localFile.sha256 != remoteFile.sha256 ||
localFile.size != remoteFile.size;
return isMismatched;
}).toList();
final extraFiles = snapshot.files
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path))
.toList();
final extraFiles = snapshot.files.where((localFile) {
if (manifest.filesByPath.containsKey(localFile.path)) {
return false;
}
return !_isIgnored(localFile.path);
}).toList();
if (snapshot.buildHash != remoteBuildHash ||
mismatchedFiles.isNotEmpty ||
@@ -492,6 +508,15 @@ class ClientSyncUseCase
}
}
/// Файлы, которые не будут удалены во время проверки файлов. Например, пользовательские
/// аддоны, скриншоты и т.д.
bool _isIgnored(String filePath) {
final normalizedPath = filePath.replaceAll('\\', '/').toLowerCase();
return Config.ignoredVerificationDirectories.any(
(pattern) => normalizedPath.contains(pattern.toLowerCase()),
);
}
Future<void> _verifyDownloadedFile({
required String installationDir,
required ClientManifestFile file,
@@ -8,10 +8,11 @@ import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
import 'package:path/path.dart' as p;
typedef InstallationScanProgressCallback =
@@ -22,10 +23,18 @@ typedef InstallationScanProgressCallback =
int totalFiles,
);
final class GameProcessHandle {
final int pid;
final Future<int> exitCode;
const GameProcessHandle({required this.pid, required this.exitCode});
}
@lazySingleton
class GameInstallationService {
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
required ClientManifest manifest,
InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled,
}) async {
@@ -125,6 +134,7 @@ class GameInstallationService {
'hashCachePath': hashCachePath,
'cachedHashes': cachedHashes,
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
'serverFiles': manifest.files.map((f) => f.path).toList(),
},
onError: errorPort.sendPort,
onExit: exitPort.sendPort,
@@ -209,7 +219,7 @@ class GameInstallationService {
await cacheDirectory.create(recursive: true);
}
Future<void> launchGame(String installationDir) async {
Future<GameProcessHandle> launchGame(String installationDir) async {
final executablePath = getExecutablePath(installationDir);
final executable = File(executablePath);
@@ -219,12 +229,16 @@ class GameInstallationService {
);
}
await Process.start(
final process = await Process.start(
executablePath,
const <String>[],
workingDirectory: installationDir,
mode: ProcessStartMode.detached,
mode: ProcessStartMode.normal,
);
await process.stdin.close();
unawaited(process.stdout.drain<void>());
unawaited(process.stderr.drain<void>());
return GameProcessHandle(pid: process.pid, exitCode: process.exitCode);
}
Future<bool> hasClientExecutable(String installationDir) async {
@@ -332,6 +346,9 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
.whereType<String>()
.map((entry) => entry.toLowerCase())
.toSet();
final serverFiles = (message['serverFiles'] as List<Object?>)
.whereType<String>()
.toSet();
final rootDirectory = Directory(installationDir);
if (!await rootDirectory.exists()) {
@@ -345,6 +362,7 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
rootDirectory.path,
pendingFiles,
ignoredDirectories,
serverFiles,
);
pendingFiles.sort(
@@ -413,41 +431,21 @@ Future<void> _collectFilesInIsolate(
String rootPath,
List<_SerializablePendingFile> pendingFiles,
Set<String> ignoredDirectories,
Set<String> serverFiles,
) async {
await for (final entity in directory.list(followLinks: false)) {
final relativePath = normalizeClientPath(
p.relative(entity.path, from: rootPath),
);
for (final path in serverFiles) {
final file = File('$rootPath/$path');
if (relativePath.isEmpty || relativePath == '.') {
if (!await file.exists()) {
continue;
}
final segments = p.posix.split(relativePath);
if (segments.isNotEmpty &&
ignoredDirectories.contains(segments.first.toLowerCase())) {
continue;
}
final stat = await file.stat();
if (entity is Directory) {
await _collectFilesInIsolate(
entity,
rootPath,
pendingFiles,
ignoredDirectories,
);
continue;
}
if (entity is! File) {
continue;
}
final stat = await entity.stat();
pendingFiles.add(
_SerializablePendingFile(
path: entity.path,
relativePath: relativePath,
path: file.path,
relativePath: normalizeClientPath(p.relative(path)),
size: stat.size,
modifiedMs: stat.modified.millisecondsSinceEpoch,
),
@@ -9,8 +9,10 @@ import 'package:moonwell_launcher/features/downloader/domain/entities/download_e
import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
@lazySingleton
@@ -58,6 +60,108 @@ class LauncherApiClient {
}
}
Future<String> register({
required String username,
required String email,
required String password,
required String passwordConfirmation,
required bool terms,
String? inviteCode,
}) async {
final registerUri = _resolveUri('api/launcher/register');
final normalizedInviteCode = inviteCode?.trim();
try {
final response = await _dio.postUri(
registerUri,
data: <String, Object?>{
'username': username,
'email': email,
'password': password,
'password_confirmation': passwordConfirmation,
'terms': terms,
if (normalizedInviteCode != null && normalizedInviteCode.isNotEmpty)
'invite_code': normalizedInviteCode,
},
);
final payload = _coerceMap(response.data);
return payload['message'] as String? ?? 'Аккаунт успешно создан.';
} on DioException catch (error, stackTrace) {
await _launcherLogService.error(
'Launcher registration request failed.',
details: <String, Object?>{
'endpoint': registerUri.toString(),
'statusCode': error.response?.statusCode,
'dioType': error.type.name,
'responseBody': _summarizePayload(error.response?.data),
},
error: error,
stackTrace: stackTrace,
);
throw LauncherApiException(_extractErrorMessage(error));
}
}
Future<List<LauncherRealm>> fetchRealms() async {
final realmsUri = _resolveUri('api/launcher/realms');
try {
final response = await _dio.getUri(realmsUri);
final payload = _coerceMap(response.data);
final rawItems = payload['data'];
if (rawItems is! List) {
throw const LauncherApiException('Unexpected launcher realms payload.');
}
return rawItems
.whereType<Map>()
.map(
(item) => LauncherRealm.fromJson(
item.map((key, value) => MapEntry(key.toString(), value)),
),
)
.toList(growable: false);
} on DioException catch (error, stackTrace) {
await _launcherLogService.error(
'Launcher realms request failed.',
details: <String, Object?>{
'endpoint': realmsUri.toString(),
'statusCode': error.response?.statusCode,
'dioType': error.type.name,
'responseBody': _summarizePayload(error.response?.data),
},
error: error,
stackTrace: stackTrace,
);
throw LauncherApiException(_extractErrorMessage(error));
}
}
Future<LauncherAccount> fetchAccount(String accessToken) async {
final accountUri = _resolveUri('api/launcher/account');
try {
final response = await _dio.getUri(
accountUri,
options: _authorizedOptions(accessToken),
);
return LauncherAccount.fromJson(_coerceMap(response.data));
} on DioException catch (error, stackTrace) {
await _launcherLogService.error(
'Launcher account request failed.',
details: <String, Object?>{
'endpoint': accountUri.toString(),
'statusCode': error.response?.statusCode,
'dioType': error.type.name,
'responseBody': _summarizePayload(error.response?.data),
},
error: error,
stackTrace: stackTrace,
);
throw LauncherApiException(_extractErrorMessage(error));
}
}
Future<ClientManifest> fetchManifest(String accessToken) async {
final manifestUri = _resolveUri('api/launcher/manifest');
@@ -2,9 +2,11 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:path/path.dart' as p;
@lazySingleton
class LauncherLogService {
static Future<void> _pendingWrite = Future<void>.value();
@@ -0,0 +1,14 @@
Uri? buildLauncherNewsUri(String rawBaseUrl, int newsId) {
final normalizedBaseUrl = rawBaseUrl.trim();
final baseUri = Uri.tryParse(
normalizedBaseUrl.endsWith('/') ? normalizedBaseUrl : '$normalizedBaseUrl/',
);
if (baseUri == null ||
!baseUri.hasScheme ||
baseUri.host.isEmpty ||
newsId <= 0) {
return null;
}
return baseUri.resolve('news/$newsId');
}
@@ -0,0 +1,99 @@
import 'dart:io';
import 'package:auto_updater/auto_updater.dart';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.dart';
abstract interface class LauncherUpdateClient {
Future<void> setFeedUrl(String feedUrl);
Future<void> disableScheduledChecks();
Future<void> checkInBackground();
}
final class AutoUpdaterLauncherUpdateClient implements LauncherUpdateClient {
@override
Future<void> setFeedUrl(String feedUrl) => autoUpdater.setFeedURL(feedUrl);
@override
Future<void> disableScheduledChecks() =>
autoUpdater.setScheduledCheckInterval(0);
@override
Future<void> checkInBackground() =>
autoUpdater.checkForUpdates(inBackground: true);
}
@lazySingleton
class LauncherUpdateService {
LauncherUpdateService(LauncherLogService launcherLogService)
: this.forTesting(
launcherLogService: launcherLogService,
updateClient: AutoUpdaterLauncherUpdateClient(),
isWindows: Platform.isWindows,
);
LauncherUpdateService.forTesting({
required LauncherLogService launcherLogService,
required LauncherUpdateClient updateClient,
required bool isWindows,
}) : _launcherLogService = launcherLogService,
_updateClient = updateClient,
_isWindows = isWindows;
final LauncherLogService _launcherLogService;
final LauncherUpdateClient _updateClient;
final bool _isWindows;
bool _hasChecked = false;
Future<void> checkForUpdates({
String feedUrl = Config.launcherAppcastUrl,
}) async {
if (!_isWindows || _hasChecked) {
return;
}
final normalizedFeedUrl = feedUrl.trim();
if (normalizedFeedUrl.isEmpty) {
await _launcherLogService.info(
'Launcher self-update is disabled because no AppCast URL is configured.',
);
return;
}
final uri = Uri.tryParse(normalizedFeedUrl);
if (uri == null ||
!uri.isAbsolute ||
uri.scheme != 'https' ||
!uri.hasAuthority ||
uri.host.isEmpty) {
await _launcherLogService.error(
'Launcher self-update AppCast URL is invalid.',
details: <String, Object?>{'feedUrl': normalizedFeedUrl},
);
return;
}
_hasChecked = true;
try {
await _updateClient.setFeedUrl(normalizedFeedUrl);
await _updateClient.disableScheduledChecks();
await _updateClient.checkInBackground();
await _launcherLogService.info(
'Launcher self-update check started.',
details: <String, Object?>{'feedUrl': normalizedFeedUrl},
);
} catch (error, stackTrace) {
await _launcherLogService.error(
'Launcher self-update check failed.',
details: <String, Object?>{'feedUrl': normalizedFeedUrl},
error: error,
stackTrace: stackTrace,
);
}
}
}
@@ -0,0 +1,11 @@
final class LauncherAccount {
final String username;
const LauncherAccount({required this.username});
factory LauncherAccount.fromJson(Map<String, dynamic> json) {
return LauncherAccount(
username: (json['username'] as String? ?? '').trim(),
);
}
}
@@ -0,0 +1,35 @@
final class LauncherRealm {
final int id;
final String name;
final String address;
final int port;
final double population;
final int gameBuild;
final bool online;
final String status;
const LauncherRealm({
required this.id,
required this.name,
required this.address,
required this.port,
required this.population,
required this.gameBuild,
required this.online,
required this.status,
});
factory LauncherRealm.fromJson(Map<String, dynamic> json) {
final online = json['online'] == true;
return LauncherRealm(
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name'] as String? ?? '',
address: json['address'] as String? ?? '',
port: (json['port'] as num?)?.toInt() ?? 0,
population: (json['population'] as num?)?.toDouble() ?? 0,
gameBuild: (json['game_build'] as num?)?.toInt() ?? 0,
online: online,
status: json['status'] as String? ?? (online ? 'online' : 'offline'),
);
}
}
@@ -0,0 +1,95 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _outputDirKey = 'output_dir';
const _launcherSessionKey = 'launcher_session';
const _themeVariantKey = 'theme_variant';
@LazySingleton(as: PreferencesRepository, env: ['flutter'])
class LocalPreferencesRepository implements PreferencesRepository {
final SharedPreferences _preferences;
final FlutterSecureStorage _secureStorage;
const LocalPreferencesRepository({
required SharedPreferences preferences,
required FlutterSecureStorage secureStorage,
}) : _preferences = preferences,
_secureStorage = secureStorage;
@override
Future<Uri?> getOutputDir() {
final rawPath = _preferences.getString(_outputDirKey);
if (rawPath == null) {
return Future.value(null);
}
return Future.value(Uri.parse(rawPath));
}
@override
Future<void> setOutputDir(Uri uri) {
return _preferences.setString(_outputDirKey, uri.toString());
}
@override
Future<LauncherSession?> getLauncherSession() async {
await _maybeClearLegacyLauncherSession();
final rawSession = await _secureStorage.read(key: _launcherSessionKey);
if (rawSession == null || rawSession.isEmpty) {
return null;
}
try {
final decoded = jsonDecode(rawSession);
if (decoded is! Map) {
return null;
}
final json = decoded.map((key, value) => MapEntry(key.toString(), value));
return LauncherSession.fromJson(json);
} catch (_) {
return null;
}
}
@override
Future<void> setLauncherSession(LauncherSession session) async {
await _secureStorage.write(
key: _launcherSessionKey,
value: jsonEncode(session.toJson()),
);
await _maybeClearLegacyLauncherSession();
}
@override
Future<void> clearLauncherSession() async {
await _secureStorage.delete(key: _launcherSessionKey);
await _maybeClearLegacyLauncherSession();
}
@override
Future<MoonWellThemeVariant> getThemeVariant() async {
return MoonWellThemeVariant.fromStoredValue(
_preferences.getString(_themeVariantKey),
);
}
@override
Future<void> setThemeVariant(MoonWellThemeVariant variant) {
return _preferences.setString(_themeVariantKey, variant.name);
}
Future<void> _maybeClearLegacyLauncherSession() async {
if (_preferences.containsKey(_launcherSessionKey)) {
await _preferences.remove(_launcherSessionKey);
}
}
}
@@ -1,67 +0,0 @@
import 'dart:convert';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _outputDirKey = 'output_dir';
const _launcherSessionKey = 'launcher_session';
@LazySingleton(as: PreferencesRepository, env: ['flutter'])
class SharedPrefsPreferencesRepository implements PreferencesRepository {
final SharedPreferences _preferences;
const SharedPrefsPreferencesRepository({
required SharedPreferences preferences,
}) : _preferences = preferences;
@override
Future<Uri?> getOutputDir() {
final rawPath = _preferences.getString(_outputDirKey);
if (rawPath == null) {
return Future.value(null);
}
return Future.value(Uri.parse(rawPath));
}
@override
Future<void> setOutputDir(Uri uri) {
return _preferences.setString(_outputDirKey, uri.toString());
}
@override
Future<LauncherSession?> getLauncherSession() {
final rawSession = _preferences.getString(_launcherSessionKey);
if (rawSession == null || rawSession.isEmpty) {
return Future.value(null);
}
try {
final decoded = jsonDecode(rawSession);
if (decoded is! Map) {
return Future.value(null);
}
final json = decoded.map((key, value) => MapEntry(key.toString(), value));
return Future.value(LauncherSession.fromJson(json));
} catch (_) {
return Future.value(null);
}
}
@override
Future<void> setLauncherSession(LauncherSession session) {
return _preferences.setString(
_launcherSessionKey,
jsonEncode(session.toJson()),
);
}
@override
Future<void> clearLauncherSession() {
return _preferences.remove(_launcherSessionKey);
}
}
@@ -1,3 +1,4 @@
import 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
abstract interface class PreferencesRepository {
@@ -10,4 +11,8 @@ abstract interface class PreferencesRepository {
Future<void> setLauncherSession(LauncherSession session);
Future<void> clearLauncherSession();
Future<MoonWellThemeVariant> getThemeVariant();
Future<void> setThemeVariant(MoonWellThemeVariant variant);
}
+19 -442
View File
@@ -1,20 +1,24 @@
// main.dart
import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, TargetPlatform;
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/mw_app.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart';
import 'package:moonwell_launcher/service_container.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:window_manager/window_manager.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
WindowOptions windowOptions = WindowOptions(
size: Size(960, 540),
await configureDependencies(env: 'flutter');
final launcherUpdateService = getIt<LauncherUpdateService>();
final launcherVersion = await _readLauncherVersion();
const windowOptions = WindowOptions(
size: Size(1280, 720),
minimumSize: Size(1280, 720),
center: true,
backgroundColor: Colors.transparent,
skipTaskbar: false,
@@ -25,445 +29,18 @@ Future<void> main() async {
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
await launcherUpdateService.checkForUpdates();
}),
);
await configureDependencies(env: 'flutter');
runApp(const MoonWellApp());
runApp(MoonWellApp(launcherVersion: launcherVersion));
}
bool get _isDesktop => const {
TargetPlatform.macOS,
TargetPlatform.linux,
TargetPlatform.windows,
}.contains(defaultTargetPlatform);
class LauncherHome extends StatefulWidget {
const LauncherHome({super.key});
@override
State<LauncherHome> createState() => _LauncherHomeState();
}
class _LauncherHomeState extends State<LauncherHome> {
double _progress = 0.0; // 0..1
bool _isDownloading = false;
double _kbps = 0; // fake speed
Duration _eta = Duration.zero;
Timer? _timer;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startDownload() {
if (_isDownloading || _progress >= 1.0) return;
setState(() => _isDownloading = true);
_timer = Timer.periodic(const Duration(milliseconds: 160), (t) {
// Fake speed & progress for demo
final chunk = 0.002 + math.Random().nextDouble() * 0.004;
_kbps = 800 + math.Random().nextDouble() * 2200; // 0.8–3.0 MB/s
_progress = (_progress + chunk).clamp(0.0, 1.0);
final remaining = (1 - _progress);
// naive ETA based on current "speed"
_eta = Duration(seconds: math.max(1, (remaining * 120).round()));
if (_progress >= 1.0) {
_isDownloading = false;
t.cancel();
}
setState(() {});
});
}
void _pause() {
_timer?.cancel();
setState(() => _isDownloading = false);
}
void _reset() {
_timer?.cancel();
setState(() {
_progress = 0;
_isDownloading = false;
_kbps = 0;
_eta = Duration.zero;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
final title = ShaderMask(
shaderCallback: (rect) =>
(deco.goldBevel as LinearGradient).createShader(rect),
child: Text(
'MOONWELL',
style: Theme.of(
context,
).textTheme.displaySmall?.copyWith(shadows: [deco.textGlow]),
),
);
if (!_isDesktop) {
return Scaffold(
body: Center(
child: Text(
'Desktop only. Please run on Windows, macOS, or Linux.',
style: Theme.of(context).textTheme.titleLarge,
),
),
);
}
return Scaffold(
body: Container(
decoration: const BoxDecoration(
// subtle vignette for “night” vibe
gradient: RadialGradient(
center: Alignment(0, -0.5),
radius: 1.2,
colors: [Color(0xFF0F1522), Color(0xFF0B101A)],
),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100, maxHeight: 760),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
// LEFT: Game title + download controls
Expanded(
flex: 3,
child: _LeftPane(
title: title,
progress: _progress,
isDownloading: _isDownloading,
kbps: _kbps,
eta: _eta,
onStart: _startDownload,
onPause: _pause,
onReset: _reset,
),
),
const SizedBox(width: 20),
// RIGHT: Patch notes
Expanded(flex: 4, child: _PatchNotesPane(color: cs.surface)),
],
),
),
),
),
),
);
}
}
class _LeftPane extends StatelessWidget {
const _LeftPane({
required this.title,
required this.progress,
required this.isDownloading,
required this.kbps,
required this.eta,
required this.onStart,
required this.onPause,
required this.onReset,
});
final Widget title;
final double progress;
final bool isDownloading;
final double kbps;
final Duration eta;
final VoidCallback onStart;
final VoidCallback onPause;
final VoidCallback onReset;
String get _speedLabel {
if (kbps <= 0) return '—';
if (kbps >= 1024) {
return '${(kbps / 1024).toStringAsFixed(2)} MB/s';
}
return '${kbps.toStringAsFixed(0)} kB/s';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
final percent = (progress * 100).clamp(0, 100).toStringAsFixed(1);
final canPlay = progress >= 1.0 && !isDownloading;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
const SizedBox(height: 16),
Text(
'Classic MMO Launcher',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: cs.onSurface.withValues(alpha: 0.85),
),
),
const SizedBox(height: 28),
// Progress bar
_GildedProgressBar(value: progress),
const SizedBox(height: 12),
Row(
children: [
Text(
'$percent%',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
Icon(Icons.speed, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 6),
Text(_speedLabel, style: TextStyle(color: cs.onSurfaceVariant)),
const SizedBox(width: 16),
Icon(
Icons.timer_outlined,
size: 18,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
eta == Duration.zero
? '—'
: '${eta.inMinutes}:${(eta.inSeconds % 60).toString().padLeft(2, '0')}',
style: TextStyle(color: cs.onSurfaceVariant),
),
],
),
const SizedBox(height: 24),
// Controls row
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: Icon(
canPlay ? Icons.play_arrow_rounded : Icons.download,
),
label: Text(
canPlay
? 'Play'
: (isDownloading
? 'Downloading…'
: (progress == 0 ? 'Install' : 'Resume')),
),
onPressed: canPlay
? () {}
: (isDownloading ? null : onStart),
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
icon: Icon(
isDownloading ? Icons.pause_rounded : Icons.replay,
),
label: Text(isDownloading ? 'Pause' : 'Reset'),
onPressed: isDownloading
? onPause
: (progress > 0 ? onReset : null),
),
],
),
const SizedBox(height: 20),
// Disk path / build info (placeholders)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline),
boxShadow: (deco.cardGlow),
),
child: Row(
children: [
const Icon(Icons.folder_open),
const SizedBox(width: 8),
Expanded(
child: Text(
'Install path: C:/Games/MoonWell',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
const Icon(Icons.info_outline, size: 18),
const SizedBox(width: 6),
Text(
'Build: 1.2.0 (live)',
style: TextStyle(color: cs.onSurfaceVariant),
),
],
),
),
],
),
),
);
}
}
class _GildedProgressBar extends StatelessWidget {
const _GildedProgressBar({required this.value});
final double value;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
return Container(
height: 20,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: cs.outline),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Fill with gold bevel
FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
alignment: Alignment.centerLeft,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: (deco.goldBevel as LinearGradient),
),
),
),
// Subtle top highlight
Align(
alignment: Alignment.topCenter,
child: Container(
height: 6,
color: Colors.white.withValues(alpha: 0.08),
),
),
],
),
);
}
}
class _PatchNotesPane extends StatelessWidget {
const _PatchNotesPane({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
// Example static notes — wire your real source here.
final notes = [
(
'1.2.0 — “Moonlit March”',
[
'New dungeon: *Hall of Tides*.',
'Launcher supports delta updates with blockmaps.',
'Improved shader warmup (faster first launch).',
'Fixed login race condition on slow networks.',
],
),
(
'1.1.5',
[
'Balance pass on Ranger & Warlock.',
'Memory usage reduced ~12% in large towns.',
],
),
];
return Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
border: Border(bottom: BorderSide(color: cs.outline)),
),
child: Row(
children: [
const Icon(Icons.article_outlined),
const SizedBox(width: 10),
Text(
'Patch Notes',
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
TextButton.icon(
onPressed: () {}, // hook to "Open full changelog"
icon: const Icon(Icons.open_in_new),
label: const Text('Full changelog'),
),
],
),
),
// Body
Expanded(
child: Scrollbar(
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
itemCount: notes.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, i) {
final (title, items) = notes[i];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...items.map(
(s) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('• '),
Expanded(
child: Text(
s,
style: TextStyle(
color: cs.onSurface.withValues(alpha: 0.9),
),
),
),
],
),
),
],
);
},
),
),
),
],
),
);
Future<String> _readLauncherVersion() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
return packageInfo.version;
} catch (_) {
return 'dev';
}
}
+30 -10
View File
@@ -1,5 +1,5 @@
// dart format width=80
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format width=80
// **************************************************************************
// InjectableConfigGenerator
@@ -9,6 +9,7 @@
// coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i558;
import 'package:get_it/get_it.dart' as _i174;
import 'package:injectable/injectable.dart' as _i526;
import 'package:shared_preferences/shared_preferences.dart' as _i460;
@@ -16,10 +17,13 @@ import 'package:shared_preferences/shared_preferences.dart' as _i460;
import 'features/launcher/application/client_sync_use_case.dart' as _i757;
import 'features/launcher/data/game_installation_service.dart' as _i315;
import 'features/launcher/data/launcher_api_client.dart' as _i703;
import 'features/preferences/data/repositories/shared_prefs_preferences_repository.dart'
as _i662;
import 'features/launcher/data/launcher_log_service.dart' as _i43;
import 'features/launcher/data/launcher_update_service.dart' as _i605;
import 'features/preferences/data/repositories/local_preferences_repository.dart'
as _i658;
import 'features/preferences/domain/repositories/preferences_repository.dart'
as _i44;
import 'third_party/flutter_secure_storage.dart' as _i782;
import 'third_party/shared_preferences.dart' as _i1006;
const String _flutter = 'flutter';
@@ -32,6 +36,7 @@ extension GetItInjectableX on _i174.GetIt {
}) async {
final gh = _i526.GetItHelper(this, environment, environmentFilter);
final sharedPreferencesModule = _$SharedPreferencesModule();
final flutterSecureStorageModule = _$FlutterSecureStorageModule();
await gh.factoryAsync<_i460.SharedPreferences>(
() => sharedPreferencesModule.prefs,
preResolve: true,
@@ -39,21 +44,36 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i315.GameInstallationService>(
() => _i315.GameInstallationService(),
);
gh.lazySingleton<_i703.LauncherApiClient>(() => _i703.LauncherApiClient());
gh.lazySingleton<_i43.LauncherLogService>(() => _i43.LauncherLogService());
gh.lazySingleton<_i558.FlutterSecureStorage>(
() => flutterSecureStorageModule.storage,
);
gh.lazySingleton<_i605.LauncherUpdateService>(
() => _i605.LauncherUpdateService(gh<_i43.LauncherLogService>()),
);
gh.lazySingleton<_i703.LauncherApiClient>(
() => _i703.LauncherApiClient(
launcherLogService: gh<_i43.LauncherLogService>(),
),
);
gh.lazySingleton<_i44.PreferencesRepository>(
() => _i658.LocalPreferencesRepository(
preferences: gh<_i460.SharedPreferences>(),
secureStorage: gh<_i558.FlutterSecureStorage>(),
),
registerFor: {_flutter},
);
gh.lazySingleton<_i757.ClientSyncUseCase>(
() => _i757.ClientSyncUseCase(
launcherApiClient: gh<_i703.LauncherApiClient>(),
installationService: gh<_i315.GameInstallationService>(),
launcherLogService: gh<_i43.LauncherLogService>(),
),
);
gh.lazySingleton<_i44.PreferencesRepository>(
() => _i662.SharedPrefsPreferencesRepository(
preferences: gh<_i460.SharedPreferences>(),
),
registerFor: {_flutter},
);
return this;
}
}
class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {}
class _$FlutterSecureStorageModule extends _i782.FlutterSecureStorageModule {}
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
@module
abstract class FlutterSecureStorageModule {
@lazySingleton
FlutterSecureStorage get storage => const FlutterSecureStorage();
}
+1
View File
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/file_picker-11.0.2/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_secure_storage_linux-3.0.1/
+1
View File
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/jni-1.0.0/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/package_info_plus-9.0.1/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/path_provider_linux-2.2.1/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/screen_retriever_linux-0.2.1/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_linux-2.4.1/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/url_launcher_linux-3.2.2/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/window_manager-0.5.1/
@@ -0,0 +1,27 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
}
@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
+28
View File
@@ -0,0 +1,28 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
screen_retriever_linux
url_launcher_linux
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)
+7
View File
@@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
@@ -0,0 +1,26 @@
//
// Generated file. Do not edit.
//
import FlutterMacOS
import Foundation
import auto_updater_macos
import file_picker
import flutter_secure_storage_darwin
import package_info_plus
import screen_retriever_macos
import shared_preferences_foundation
import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
+42
View File
@@ -0,0 +1,42 @@
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end
+29
View File
@@ -0,0 +1,29 @@
PODS:
- auto_updater_macos (0.0.1):
- FlutterMacOS
- Sparkle
- FlutterMacOS (1.0.0)
- Sparkle (2.9.3)
DEPENDENCIES:
- auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
SPEC REPOS:
trunk:
- Sparkle
EXTERNAL SOURCES:
auto_updater_macos:
:path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos
FlutterMacOS:
:path: Flutter/ephemeral
SPEC CHECKSUMS:
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
Sparkle: 39dbdf28637f6056fbf5ee6494bdbfae54f49f41
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
COCOAPODS: 1.16.2
+827
View File
@@ -0,0 +1,827 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXAggregateTarget section */
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
isa = PBXAggregateTarget;
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
buildPhases = (
33CC111E2044C6BF0003C045 /* ShellScript */,
);
dependencies = (
);
name = "Flutter Assemble";
productName = FLX;
};
/* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */
020359DC38F00FB8C06FA10B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 291B63A92E3559FEDBFE0A5B /* Pods_Runner.framework */; };
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
F90EB2907BAACC032B0C41D7 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D18013F1C238316DA220246 /* Pods_RunnerTests.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC10EC2044A3C60003C045;
remoteInfo = Runner;
};
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
remoteInfo = FLX;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
33CC110E2044A8840003C045 /* Bundle Framework */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Bundle Framework";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
26D772D80F4314E77A923A58 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
291B63A92E3559FEDBFE0A5B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* moonwell_launcher.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = moonwell_launcher.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
3C09F7DDD37436AD1ECCB3AF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
3D18013F1C238316DA220246 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
475A3564419F2746BD62A61F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
5BF452EF3FF15FC576C47615 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
8ACA2734D27028484BB5A7C4 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
CAB0C10E5CA9D8568F3B0CBA /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
331C80D2294CF70F00263BE5 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
F90EB2907BAACC032B0C41D7 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EA2044A3C60003C045 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
020359DC38F00FB8C06FA10B /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C80D6294CF71000263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C80D7294CF71000263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
33BA886A226E78AF003329D5 /* Configs */ = {
isa = PBXGroup;
children = (
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
);
path = Configs;
sourceTree = "<group>";
};
33CC10E42044A3C60003C045 = {
isa = PBXGroup;
children = (
33FAB671232836740065AC1E /* Runner */,
33CEB47122A05771004F2AC0 /* Flutter */,
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
564B720168AF1FFCA71E91CF /* Pods */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* moonwell_launcher.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
33CC11242044D66E0003C045 /* Resources */ = {
isa = PBXGroup;
children = (
33CC10F22044A3C60003C045 /* Assets.xcassets */,
33CC10F42044A3C60003C045 /* MainMenu.xib */,
33CC10F72044A3C60003C045 /* Info.plist */,
);
name = Resources;
path = ..;
sourceTree = "<group>";
};
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
);
path = Flutter;
sourceTree = "<group>";
};
33FAB671232836740065AC1E /* Runner */ = {
isa = PBXGroup;
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
33BA886A226E78AF003329D5 /* Configs */,
);
path = Runner;
sourceTree = "<group>";
};
564B720168AF1FFCA71E91CF /* Pods */ = {
isa = PBXGroup;
children = (
5BF452EF3FF15FC576C47615 /* Pods-Runner.debug.xcconfig */,
8ACA2734D27028484BB5A7C4 /* Pods-Runner.release.xcconfig */,
475A3564419F2746BD62A61F /* Pods-Runner.profile.xcconfig */,
CAB0C10E5CA9D8568F3B0CBA /* Pods-RunnerTests.debug.xcconfig */,
26D772D80F4314E77A923A58 /* Pods-RunnerTests.release.xcconfig */,
3C09F7DDD37436AD1ECCB3AF /* Pods-RunnerTests.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
291B63A92E3559FEDBFE0A5B /* Pods_Runner.framework */,
3D18013F1C238316DA220246 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C80D4294CF70F00263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
F14A13D2E5EBDA6D29BEC075 /* [CP] Check Pods Manifest.lock */,
331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C80DA294CF71000263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
33CC10EC2044A3C60003C045 /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
581A4F54A9F4DBD7EFA77BEB /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
D3D7E8D1C9E3D26079DAFBF5 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* moonwell_launcher.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C80D4294CF70F00263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 33CC10EC2044A3C60003C045;
};
33CC10EC2044A3C60003C045 = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 1;
};
};
};
33CC111A2044C6BA0003C045 = {
CreatedOnToolsVersion = 9.2;
ProvisioningStyle = Manual;
};
};
};
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
33CC10EC2044A3C60003C045 /* Runner */,
331C80D4294CF70F00263BE5 /* RunnerTests */,
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C80D3294CF70F00263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EB2044A3C60003C045 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
};
33CC111E2044C6BF0003C045 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
Flutter/ephemeral/FlutterInputs.xcfilelist,
);
inputPaths = (
Flutter/ephemeral/tripwire,
);
outputFileListPaths = (
Flutter/ephemeral/FlutterOutputs.xcfilelist,
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
581A4F54A9F4DBD7EFA77BEB /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
D3D7E8D1C9E3D26079DAFBF5 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
F14A13D2E5EBDA6D29BEC075 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C80D1294CF70F00263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10E92044A3C60003C045 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC10EC2044A3C60003C045 /* Runner */;
targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
};
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
isa = PBXVariantGroup;
children = (
33CC10F52044A3C60003C045 /* Base */,
);
name = MainMenu.xib;
path = Runner;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = CAB0C10E5CA9D8568F3B0CBA /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.moonwellLauncher.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/moonwell_launcher.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/moonwell_launcher";
};
name = Debug;
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 26D772D80F4314E77A923A58 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.moonwellLauncher.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/moonwell_launcher.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/moonwell_launcher";
};
name = Release;
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 3C09F7DDD37436AD1ECCB3AF /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.moonwellLauncher.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/moonwell_launcher.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/moonwell_launcher";
};
name = Profile;
};
338D0CE9231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Profile;
};
338D0CEA231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = YZDQUQZJ77;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Profile;
};
338D0CEB231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Profile;
};
33CC10F92044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
33CC10FA2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
33CC10FC2044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = YZDQUQZJ77;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
33CC10FD2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Release;
};
33CC111C2044C6BA0003C045 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
};
33CC111D2044C6BA0003C045 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C80DB294CF71000263BE5 /* Debug */,
331C80DC294CF71000263BE5 /* Release */,
331C80DD294CF71000263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10F92044A3C60003C045 /* Debug */,
33CC10FA2044A3C60003C045 /* Release */,
338D0CE9231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10FC2044A3C60003C045 /* Debug */,
33CC10FD2044A3C60003C045 /* Release */,
338D0CEA231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC111C2044C6BA0003C045 /* Debug */,
33CC111D2044C6BA0003C045 /* Release */,
338D0CEB231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "&quot;$FLUTTER_ROOT&quot;/packages/flutter_tools/bin/macos_assemble.sh prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "moonwell_launcher.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "moonwell_launcher.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "moonwell_launcher.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C80D4294CF70F00263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "moonwell_launcher.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "moonwell_launcher.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
+13
View File
@@ -0,0 +1,13 @@
import Cocoa
import FlutterMacOS
@main
class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
}
}
@@ -0,0 +1,68 @@
{
"images" : [
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_16.png",
"scale" : "1x"
},
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"scale" : "2x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"scale" : "1x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_64.png",
"scale" : "2x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_128.png",
"scale" : "1x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"scale" : "2x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"scale" : "1x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"scale" : "2x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"scale" : "1x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_1024.png",
"scale" : "2x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+343
View File
@@ -0,0 +1,343 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14490.70"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="GzC-gU-4Uq"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="Runner" customModuleProvider="target">
<connections>
<outlet property="applicationMenu" destination="uQy-DD-JDr" id="XBo-yE-nKs"/>
<outlet property="mainFlutterWindow" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
</connections>
</customObject>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
<menuItem title="APP_NAME" id="1Xt-HY-uBw">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="APP_NAME" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About APP_NAME" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontStandardAboutPanel:" target="-1" id="Exp-CZ-Vem"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
<menuItem title="Services" id="NMo-om-nkz">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Hide APP_NAME" keyEquivalent="h" id="Olw-nP-bQN">
<connections>
<action selector="hide:" target="-1" id="PnN-Uc-m68"/>
</connections>
</menuItem>
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="hideOtherApplications:" target="-1" id="VT4-aY-XCT"/>
</connections>
</menuItem>
<menuItem title="Show All" id="Kd2-mp-pUS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="unhideAllApplications:" target="-1" id="Dhg-Le-xox"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
<menuItem title="Quit APP_NAME" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="-1" id="Te7-pn-YzF"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Edit" id="5QF-Oa-p0T">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="W48-6f-4Dl">
<items>
<menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
<connections>
<action selector="undo:" target="-1" id="M6e-cu-g7V"/>
</connections>
</menuItem>
<menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
<connections>
<action selector="redo:" target="-1" id="oIA-Rs-6OD"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
<menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
<connections>
<action selector="cut:" target="-1" id="YJe-68-I9s"/>
</connections>
</menuItem>
<menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
<connections>
<action selector="copy:" target="-1" id="G1f-GL-Joy"/>
</connections>
</menuItem>
<menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
<connections>
<action selector="paste:" target="-1" id="UvS-8e-Qdg"/>
</connections>
</menuItem>
<menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="pasteAsPlainText:" target="-1" id="cEh-KX-wJQ"/>
</connections>
</menuItem>
<menuItem title="Delete" id="pa3-QI-u2k">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="delete:" target="-1" id="0Mk-Ml-PaM"/>
</connections>
</menuItem>
<menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
<connections>
<action selector="selectAll:" target="-1" id="VNm-Mi-diN"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
<menuItem title="Find" id="4EN-yA-p0u">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Find" id="1b7-l0-nxx">
<items>
<menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
<connections>
<action selector="performFindPanelAction:" target="-1" id="cD7-Qs-BN4"/>
</connections>
</menuItem>
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="performFindPanelAction:" target="-1" id="WD3-Gg-5AJ"/>
</connections>
</menuItem>
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
<connections>
<action selector="performFindPanelAction:" target="-1" id="NDo-RZ-v9R"/>
</connections>
</menuItem>
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
<connections>
<action selector="performFindPanelAction:" target="-1" id="HOh-sY-3ay"/>
</connections>
</menuItem>
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
<connections>
<action selector="performFindPanelAction:" target="-1" id="U76-nv-p5D"/>
</connections>
</menuItem>
<menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
<connections>
<action selector="centerSelectionInVisibleArea:" target="-1" id="IOG-6D-g5B"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
<items>
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
<connections>
<action selector="showGuessPanel:" target="-1" id="vFj-Ks-hy3"/>
</connections>
</menuItem>
<menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
<connections>
<action selector="checkSpelling:" target="-1" id="fz7-VC-reM"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
<menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleContinuousSpellChecking:" target="-1" id="7w6-Qz-0kB"/>
</connections>
</menuItem>
<menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleGrammarChecking:" target="-1" id="muD-Qn-j4w"/>
</connections>
</menuItem>
<menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticSpellingCorrection:" target="-1" id="2lM-Qi-WAP"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Substitutions" id="9ic-FL-obx">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
<items>
<menuItem title="Show Substitutions" id="z6F-FW-3nz">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontSubstitutionsPanel:" target="-1" id="oku-mr-iSq"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
<menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleSmartInsertDelete:" target="-1" id="3IJ-Se-DZD"/>
</connections>
</menuItem>
<menuItem title="Smart Quotes" id="hQb-2v-fYv">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticQuoteSubstitution:" target="-1" id="ptq-xd-QOA"/>
</connections>
</menuItem>
<menuItem title="Smart Dashes" id="rgM-f4-ycn">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDashSubstitution:" target="-1" id="oCt-pO-9gS"/>
</connections>
</menuItem>
<menuItem title="Smart Links" id="cwL-P1-jid">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticLinkDetection:" target="-1" id="Gip-E3-Fov"/>
</connections>
</menuItem>
<menuItem title="Data Detectors" id="tRr-pd-1PS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDataDetection:" target="-1" id="R1I-Nq-Kbl"/>
</connections>
</menuItem>
<menuItem title="Text Replacement" id="HFQ-gK-NFA">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticTextReplacement:" target="-1" id="DvP-Fe-Py6"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Transformations" id="2oI-Rn-ZJC">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Transformations" id="c8a-y6-VQd">
<items>
<menuItem title="Make Upper Case" id="vmV-6d-7jI">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="uppercaseWord:" target="-1" id="sPh-Tk-edu"/>
</connections>
</menuItem>
<menuItem title="Make Lower Case" id="d9M-CD-aMd">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="lowercaseWord:" target="-1" id="iUZ-b5-hil"/>
</connections>
</menuItem>
<menuItem title="Capitalize" id="UEZ-Bs-lqG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="capitalizeWord:" target="-1" id="26H-TL-nsh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Speech" id="xrE-MZ-jX0">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Speech" id="3rS-ZA-NoH">
<items>
<menuItem title="Start Speaking" id="Ynk-f8-cLZ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="startSpeaking:" target="-1" id="654-Ng-kyl"/>
</connections>
</menuItem>
<menuItem title="Stop Speaking" id="Oyz-dy-DGm">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="stopSpeaking:" target="-1" id="dX8-6p-jy9"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="View" id="H8h-7b-M4v">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="View" id="HyV-fh-RgO">
<items>
<menuItem title="Enter Full Screen" keyEquivalent="f" id="4J7-dP-txa">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>
<action selector="toggleFullScreen:" target="-1" id="dU3-MA-1Rq"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Window" id="aUF-d1-5bR">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
<items>
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
<connections>
<action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
</connections>
</menuItem>
<menuItem title="Zoom" id="R4o-n2-Eq4">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="arrangeInFront:" target="-1" id="DRN-fu-gQh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="EPT-qC-fAb">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="rJ0-wn-3NY"/>
</menuItem>
</items>
<point key="canvasLocation" x="142" y="-258"/>
</menu>
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
<autoresizingMask key="autoresizingMask"/>
</view>
</window>
</objects>
</document>
+14
View File
@@ -0,0 +1,14 @@
// Application-level settings for the Runner target.
//
// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
// future. If not, the values below would default to using the project name when this becomes a
// 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = moonwell_launcher
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.moonwellLauncher
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.
+2
View File
@@ -0,0 +1,2 @@
#include "../../Flutter/Flutter-Debug.xcconfig"
#include "Warnings.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include "../../Flutter/Flutter-Release.xcconfig"
#include "Warnings.xcconfig"
+13
View File
@@ -0,0 +1,13 @@
WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings
GCC_WARN_UNDECLARED_SELECTOR = YES
CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES
CLANG_WARN_PRAGMA_PACK = YES
CLANG_WARN_STRICT_PROTOTYPES = YES
CLANG_WARN_COMMA = YES
GCC_WARN_STRICT_SELECTOR_MATCH = YES
CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES
GCC_WARN_SHADOW = YES
CLANG_WARN_UNREACHABLE_CODE = YES
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true />
<key>com.apple.security.cs.allow-jit</key>
<true />
<key>com.apple.security.network.server</key>
<true />
<key>com.apple.security.network.client</key>
<true />
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.example.moonwellLauncher</string>
</array>
</dict>
</plist>
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
+15
View File
@@ -0,0 +1,15 @@
import Cocoa
import FlutterMacOS
class MainFlutterWindow: NSWindow {
override func awakeFromNib() {
let flutterViewController = FlutterViewController()
let windowFrame = self.frame
self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true)
RegisterGeneratedPlugins(registry: flutterViewController)
super.awakeFromNib()
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More