chore: New UI implementation #1

Merged
sindoring merged 4 commits from feat/ui into master 2026-07-28 19:44:15 +03:00
145 changed files with 16274 additions and 1308 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "stable"
}
+3 -1
View File
@@ -1 +1,3 @@
{}
{
"dart.flutterSdkPath": ".fvm/versions/stable"
}
+19 -12
View File
@@ -17,6 +17,9 @@ Important areas:
- `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
@@ -32,26 +35,28 @@ Important areas:
## Flutter SDK Policy
MoonWell Launcher supports the latest stable Flutter SDK only. Do not introduce
or rely on FVM-pinned workflows.
If `flutter` is not available after installing or updating Flutter, restart the
shell and verify PATH with `flutter --version`.
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
flutter pub get
dart run build_runner build --delete-conflicting-outputs
dart format --set-exit-if-changed .
flutter analyze
flutter test
flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
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
@@ -102,3 +107,5 @@ or local-only API URLs.
- 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.
+49 -17
View File
@@ -7,8 +7,10 @@ without excessive grind and with stronger social play.
The launcher is responsible for:
- 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
## Stack
@@ -23,28 +25,27 @@ The launcher is responsible for:
## Prerequisites
Use the latest stable Flutter SDK available from the official Flutter release
channel. This project does not support FVM-pinned SDK workflows.
Install FVM. The repository `.fvmrc` tracks the latest stable Flutter channel,
and all Dart and Flutter commands are run through FVM.
```powershell
flutter pub get
fvm flutter pub get
```
If `flutter` is unavailable, restart the shell after installing Flutter or
fix PATH so `flutter --version` works.
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://host
--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://host/launcher/updates/appcast.xml
--dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The updater is disabled when `MOONWELL_APPCAST_URL` is omitted. Production
@@ -53,11 +54,22 @@ feeds must use HTTPS.
Example run command:
```powershell
flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
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
@@ -76,29 +88,47 @@ pre-commit workflow.
Build the Windows launcher:
```powershell
flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
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.
## Architecture
The UI lives in `lib/app`, with shared UI widgets under `lib/app/widgets`.
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
3. scan the selected installation directory
4. compare local files to the manifest
5. remove stale files outside ignored directories
6. download missing or changed files to temporary part files
7. verify size and SHA-256 before replacing client files
8. launch `Wow.exe` from the selected client directory
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.
@@ -111,3 +141,5 @@ See `docs/launcher_web_api_spec.md` for the API and sync contract.
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
+22
View File
@@ -0,0 +1,22 @@
<?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</description>
<language>ru</language>
<item>
<title>MoonWell Launcher 1.0.1</title>
<description>Обновление интерфейса, синхронизации клиента и интеграции с сервером MoonWell.</description>
<pubDate>Tue, 28 Jul 2026 20:33:00 +0400</pubDate>
<enclosure
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
sparkle:version="1.0.1"
sparkle:shortVersionString="1.0.1"
sparkle:os="windows"
sparkle:dsaSignature="MD4CHQCdoCW5jNymVlRblQpqy8C+jw4W0rVFQX0Bk25PAh0Av2ZcFYwZFQBQlAstQXna/PsuMNWIzXF4jpDuSA=="
length="19989772"
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

+24 -6
View File
@@ -15,8 +15,8 @@ Pass the public feed URL at build time:
```powershell
flutter build windows `
--dart-define=MOONWELL_API_BASE_URL=https://host `
--dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
--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
@@ -67,10 +67,9 @@ Use a public AppCast with absolute URLs:
<language>ru</language>
<item>
<title>MoonWell Launcher 1.1.0</title>
<sparkle:releaseNotesLink>https://host/launcher/updates/1.1.0/release-notes.html</sparkle:releaseNotesLink>
<pubDate>Sun, 21 Jun 2026 12:00:00 +0400</pubDate>
<enclosure
url="https://host/launcher/updates/1.1.0/moonwell_launcher_1.1.0_windows_setup.exe"
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
sparkle:version="1.1.0"
sparkle:os="windows"
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
@@ -90,6 +89,12 @@ to an authenticated endpoint.
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.
@@ -109,10 +114,23 @@ invokes WinSparkle's Windows signing utility.
(Get-Item build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe).Length
```
7. Upload the versioned installer and release notes first.
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. Publish `appcast.xml` last so clients never observe an incomplete release.
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
+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
+6 -6
View File
@@ -1,19 +1,19 @@
# Linting And Test Flow
MoonWell Launcher supports the latest stable Flutter SDK only. Do not use FVM
for this project.
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
dart format --set-exit-if-changed .
flutter analyze
flutter test
fvm dart format --set-exit-if-changed .
fvm flutter analyze
fvm flutter test
```
Use `dart format .` to apply formatting fixes when the format check fails.
Use `fvm dart format .` to apply formatting fixes when the format check fails.
## Lefthook
+152
View File
@@ -0,0 +1,152 @@
# 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
```
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.
## Required Environment
The script reads secrets and storage configuration only from environment
variables:
| 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` |
Load secrets from the team secret manager into the current PowerShell process.
Do not put real values in a tracked `.env` file or in the script.
## Prepare a Release
Increase both parts of the version in `pubspec.yaml`:
```yaml
version: 1.0.2+3
```
`X.Y.Z` is the WinSparkle release version. `N` is the Flutter build number.
Every production release must have an `X.Y.Z` value greater than the version
currently published in AppCast.
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.
+4 -4
View File
@@ -83,13 +83,13 @@ 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 `flutter pub get`.
2. Run `fvm flutter pub get`.
3. Regenerate dependency injection only if dependency resolution or generator
output requires it:
`dart run build_runner build --delete-conflicting-outputs`.
4. Run `flutter analyze`.
`fvm dart run build_runner build --delete-conflicting-outputs`.
4. Run `fvm flutter analyze`.
5. Run a Windows build smoke test:
`flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`.
`fvm flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`.
## Recommended Refactoring Direction
+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
@@ -0,0 +1,331 @@
import 'package:flutter/material.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;
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),
const Center(
child: Text(
'MOONWELL · V 1.0.0 · PLAY.MOON-WELL.ONLINE',
style: 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 : const Color(0x247FB8D4),
),
),
),
child: Text(
label,
style: TextStyle(
fontFamily: 'Cinzel',
color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
fontSize: 9,
letterSpacing: 1.5,
),
),
),
),
);
}
@@ -0,0 +1,846 @@
import 'package:flutter/material.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) => 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),
const Text('Лаунчер v1.0.0'),
],
),
),
);
}
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);
}
}
+539
View File
@@ -0,0 +1,539 @@
import 'package:flutter/material.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) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: const BoxDecoration(
color: Color(0xD904060E),
border: Border(top: BorderSide(color: Color(0x247FB8D4))),
),
child: const Row(
children: [
Text('Версия лаунчера 1.0.0'),
SizedBox(width: 24),
Text('·'),
SizedBox(width: 24),
Text('Соединение защищено TLS'),
Spacer(),
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),
),
),
),
),
);
}
}
+176 -2
View File
@@ -26,11 +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,
_startupManifest = manifest,
super(
HomeScreenState(
model: HomeScreenModel.initial().copyWith(
@@ -41,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;
@@ -58,6 +75,10 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
StreamSubscription<ClientSyncStatus>? _syncSubscription;
final LauncherSession _session;
final ClientManifest _startupManifest;
Timer? _manifestRefreshTimer;
Timer? _realmRefreshTimer;
int? _activeGamePid;
bool _pauseRequested = false;
Future<void> _onHomeScreenLoad(
@@ -82,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,
@@ -107,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;
}
@@ -134,6 +251,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
errorMessage: null,
processedFiles: 0,
totalFiles: 0,
syncStage: null,
),
),
);
@@ -144,6 +262,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
request: ClientSyncRequest(
installationDir: outputPath.toFilePath(),
accessToken: _session.accessToken,
manifest: event.manifest,
),
isCancelled: () async => _pauseRequested,
),
@@ -227,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,
),
@@ -248,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();
@@ -262,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,
@@ -273,6 +440,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
newsItems: const [],
isLoadingNews: false,
newsErrorMessage: null,
realm: null,
),
),
);
@@ -303,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,
),
),
);
@@ -327,6 +496,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
statusText: 'Обновление приостановлено.',
currentPath: null,
errorMessage: null,
syncStage: null,
),
),
);
@@ -341,6 +511,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
statusText: 'Не удалось завершить обновление клиента.',
currentPath: null,
errorMessage: _formatError(event.error),
syncStage: null,
),
),
);
@@ -420,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?,
);
}
}
+1 -34
View File
@@ -3,8 +3,6 @@ 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/app/widgets/window_title_bar.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';
@@ -44,38 +42,7 @@ class HomeScreen extends StatelessWidget {
),
);
},
child: Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
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],
),
),
),
),
const Positioned.fill(child: HomeScreenContent()),
const Positioned(
top: 0,
left: 0,
right: 0,
child: WindowTitleBar(),
),
],
),
),
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),
),
),
),
),
],
),
);
}
}
+130 -109
View File
@@ -1,10 +1,13 @@
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/app/widgets/window_title_bar.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';
import 'package:window_manager/window_manager.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@@ -15,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();
@@ -38,6 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() {
_isLoading = true;
_error = null;
_success = null;
});
try {
@@ -68,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);
@@ -76,114 +176,35 @@ class _LoginScreenState extends State<LoginScreen> {
@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),
],
),
),
),
),
const Positioned(top: 0, left: 0, right: 0, child: WindowTitleBar()),
Center(
child: SizedBox(
width: 340,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/logo.png',
height: 160,
filterQuality: FilterQuality.high,
),
const SizedBox(height: 32),
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()),
);
}
Future<void> _toggleMaximized() async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
}
}
+62 -50
View File
@@ -1,25 +1,68 @@
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/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 {
class MoonWellApp extends StatefulWidget {
const MoonWellApp({super.key});
@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, _) => 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 +91,6 @@ class _LauncherBootstrapScreenState extends State<_LauncherBootstrapScreen> {
if (snapshot.connectionState != ConnectionState.done) {
return const _LauncherBootstrapLoadingScreen();
}
final result =
snapshot.data ??
const RestoreLauncherSessionResult.unauthenticated();
@@ -58,7 +100,6 @@ class _LauncherBootstrapScreenState extends State<_LauncherBootstrapScreen> {
manifest: result.manifest!,
);
}
return const LoginScreen();
},
);
@@ -70,50 +111,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),
),
],
),
],
);
}
-77
View File
@@ -1,77 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:window_manager/window_manager.dart';
class WindowTitleBar extends StatelessWidget {
const WindowTitleBar({super.key});
@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: () {
unawaited(_toggleMaximized());
},
color: cs.onSurface,
),
_TitleBarButton(
icon: Icons.close,
onTap: () {
unawaited(windowManager.destroy());
},
color: MWColors.error,
),
],
),
),
);
}
Future<void> _toggleMaximized() async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
}
}
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)),
),
);
}
}
+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,
);
}
}
@@ -149,7 +149,7 @@ class ClientSyncUseCase
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is up to date.',
message: 'Клиент обновлён.',
currentPath: null,
localBuildHash: localSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
@@ -225,7 +225,7 @@ class ClientSyncUseCase
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is ready to play.',
message: 'Клиент готов к игре.',
currentPath: null,
localBuildHash: verifiedSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
@@ -22,6 +22,13 @@ 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(
@@ -209,7 +216,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 +226,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 {
@@ -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');
@@ -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,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'),
);
}
}
@@ -2,12 +2,14 @@ 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 {
@@ -73,6 +75,18 @@ class LocalPreferencesRepository implements PreferencesRepository {
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,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);
}
+3 -2
View File
@@ -14,8 +14,9 @@ Future<void> main() async {
await configureDependencies(env: 'flutter');
final launcherUpdateService = getIt<LauncherUpdateService>();
WindowOptions windowOptions = WindowOptions(
size: Size(960, 540),
const windowOptions = WindowOptions(
size: Size(1280, 720),
minimumSize: Size(1280, 720),
center: true,
backgroundColor: Colors.transparent,
skipTaskbar: false,
+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/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)
@@ -10,6 +10,7 @@ import file_picker
import flutter_secure_storage_darwin
import screen_retriever_macos
import shared_preferences_foundation
import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
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"))
}
-32
View File
@@ -2,29 +2,12 @@ PODS:
- auto_updater_macos (0.0.1):
- FlutterMacOS
- Sparkle
- file_picker (0.0.1):
- FlutterMacOS
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- FlutterMacOS (1.0.0)
- screen_retriever_macos (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- Sparkle (2.9.3)
- window_manager (0.5.0):
- FlutterMacOS
DEPENDENCIES:
- auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- FlutterMacOS (from `Flutter/ephemeral`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
SPEC REPOS:
trunk:
@@ -33,28 +16,13 @@ SPEC REPOS:
EXTERNAL SOURCES:
auto_updater_macos:
:path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
flutter_secure_storage_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin
FlutterMacOS:
:path: Flutter/ephemeral
screen_retriever_macos:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
window_manager:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
Sparkle: 39dbdf28637f6056fbf5ee6494bdbfae54f49f41
window_manager: b729e31d38fb04905235df9ea896128991cad99e
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
+22
View File
@@ -29,6 +29,7 @@
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 */
@@ -88,6 +89,7 @@
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 */
@@ -103,6 +105,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
020359DC38F00FB8C06FA10B /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -164,6 +167,7 @@
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
@@ -230,6 +234,9 @@
productType = "com.apple.product-type.bundle.unit-test";
};
33CC10EC2044A3C60003C045 /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -255,6 +262,9 @@
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
@@ -800,6 +810,18 @@
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 */;
}
@@ -5,6 +5,24 @@
<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"
+4612
View File
File diff suppressed because one or more lines are too long
+407 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.0",
"info": {
"title": "Moonwell Launcher API",
"description": "API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.",
"description": "API для лаунчера WoW-клиента Moonwell. Авторизация, новости, реалмы и их статусы, получение манифеста файлов и скачивание обновлений.",
"version": "1.0.0"
},
"paths": {
@@ -274,6 +274,402 @@
}
]
}
},
"/api/service/update-appcast": {
"post": {
"tags": [
"Launcher Service"
],
"summary": "Обновить appcast лаунчера",
"description": "Служебный endpoint для разработчика лаунчера. Принимает готовый XML appcast в формате RSS 2.0/Sparkle, который использует Flutter-пакет `upgrader`, и сохраняет его как публичный `/appcast.xml` на сервере. Тело запроса передается как raw XML, без JSON-обертки.",
"operationId": "0b1b8798e0eefc46277891ccc1440afb",
"requestBody": {
"required": true,
"content": {
"application/xml": {
"schema": {
"type": "string",
"example": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\" xmlns:sparkle=\"http://www.andymatuschak.org/xml-namespaces/sparkle\">\n <channel>\n <title>Moonwell Launcher - Appcast</title>\n <item>\n <title>Version 1.2.0</title>\n <description>Описание изменений для окна обновления.</description>\n <pubDate>Sat, 16 May 2026 12:00:00 +0400</pubDate>\n <enclosure url=\"https://moonwell.su/launcher/download\" sparkle:version=\"1.2.0\" sparkle:os=\"windows\" />\n </item>\n </channel>\n</rss>"
}
}
}
},
"responses": {
"200": {
"description": "Appcast сохранен",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "XML saved"
}
},
"type": "object"
}
}
}
},
"403": {
"description": "Нет доступа или передано пустое тело запроса",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "empty body"
}
},
"type": "object"
}
}
}
},
"500": {
"description": "Не удалось сохранить appcast.xml",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Failed to save XML"
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"launcherAuth": []
}
]
}
},
"/api/launcher/news": {
"get": {
"tags": [
"Launcher"
],
"summary": "Список новостей лаунчера",
"description": "Возвращает опубликованные новости для отображения в лаунчере. Отсортированы по sort_order и дате создания.",
"operationId": "69aa09e0c7cca466e8a45a186444245e",
"responses": {
"200": {
"description": "Список новостей",
"content": {
"application/json": {
"schema": {
"properties": {
"data": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer",
"example": 1
},
"title": {
"type": "string",
"example": "Обновление 1.2"
},
"body": {
"type": "string",
"example": "Описание обновления..."
},
"image_url": {
"type": "string",
"format": "uri",
"example": "https://storage.yandexcloud.net/warcraft-client/news/update-1.2.jpg",
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time"
}
},
"type": "object"
}
}
},
"type": "object"
}
}
}
},
"401": {
"description": "Не авторизован",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Unauthenticated."
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/launcher/realms": {
"get": {
"tags": [
"Launcher"
],
"summary": "Получить список реалмов и их статус",
"description": "Возвращает реалмы из таблицы realmlist. Статус online означает, что игровой порт реалма доступен по TCP.",
"operationId": "6d142b37a7b9a121b7ad02fd6405e054",
"responses": {
"200": {
"description": "Список реалмов",
"content": {
"application/json": {
"schema": {
"properties": {
"data": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer",
"example": 1
},
"name": {
"type": "string",
"example": "MoonWell"
},
"address": {
"type": "string",
"example": "logon.moon-well.online"
},
"port": {
"type": "integer",
"example": 8085
},
"icon": {
"type": "integer",
"example": 1
},
"flag": {
"type": "integer",
"example": 0
},
"timezone": {
"type": "integer",
"example": 4
},
"population": {
"type": "number",
"format": "float",
"example": 0.5
},
"game_build": {
"type": "integer",
"example": 12340
},
"online": {
"type": "boolean",
"example": true
},
"status": {
"type": "string",
"example": "online",
"enum": [
"online",
"offline"
]
}
},
"type": "object"
}
},
"checked_at": {
"type": "string",
"format": "date-time"
}
},
"type": "object"
}
}
}
},
"503": {
"description": "Не удалось получить список реалмов",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Не удалось получить список реалмов."
}
},
"type": "object"
}
}
}
},
"429": {
"description": "Слишком много запросов"
}
}
}
},
"/api/launcher/register": {
"post": {
"tags": [
"Launcher Auth"
],
"summary": "Зарегистрировать игровой аккаунт",
"description": "Создаёт игровой аккаунт AzerothCore. Инвайт-код обязателен, если на сервере включена регистрация по приглашениям.",
"operationId": "bfc1dbd42cbcc339bc8a13dbf189c525",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"username",
"email",
"password",
"password_confirmation",
"terms"
],
"properties": {
"username": {
"type": "string",
"pattern": "^[A-Za-z0-9]+$",
"example": "Player",
"maxLength": 32,
"minLength": 3
},
"email": {
"type": "string",
"format": "email",
"example": "player@example.com",
"maxLength": 255
},
"invite_code": {
"type": "string",
"example": "ABCD-EFGH-IJKL",
"nullable": true,
"maxLength": 32,
"minLength": 6
},
"password": {
"type": "string",
"example": "Secret123",
"maxLength": 32,
"minLength": 8
},
"password_confirmation": {
"type": "string",
"example": "Secret123",
"maxLength": 32,
"minLength": 8
},
"terms": {
"description": "Согласие с правилами сервера и пользовательским соглашением",
"type": "boolean",
"example": true
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Аккаунт создан",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Игровой аккаунт успешно создан."
},
"account": {
"properties": {
"id": {
"type": "integer",
"example": 77
},
"username": {
"type": "string",
"example": "PLAYER"
}
},
"type": "object"
}
},
"type": "object"
}
}
}
},
"422": {
"description": "Ошибка валидации, занятый логин или неверный инвайт-код",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Такой игровой аккаунт уже существует."
},
"errors": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"type": "object"
}
}
}
},
"500": {
"description": "Внутренняя ошибка регистрации",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Не удалось создать аккаунт. Попробуй снова позже."
}
},
"type": "object"
}
}
}
},
"429": {
"description": "Слишком много запросов"
}
}
}
}
},
"components": {
@@ -283,6 +679,12 @@
"description": "Токен, полученный через POST /api/launcher/login",
"bearerFormat": "JWT",
"scheme": "bearer"
},
"launcherAuth": {
"type": "apiKey",
"description": "Сервисный ключ из LAUNCHER_AUTH_KEY для запросов лаунчера к служебным endpoint-ам.",
"name": "Auth",
"in": "header"
}
}
},
@@ -294,6 +696,10 @@
{
"name": "Launcher",
"description": "Launcher"
},
{
"name": "Launcher Service",
"description": "Launcher Service"
}
]
}
+72 -8
View File
@@ -141,10 +141,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -540,18 +540,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.18"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
@@ -905,10 +905,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.8"
version: "0.7.7"
typed_data:
dependency: transitive
description:
@@ -917,6 +917,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
+18 -2
View File
@@ -16,10 +16,10 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 1.0.1+2
environment:
sdk: ^3.9.0
sdk: ">=3.10.0"
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
@@ -46,6 +46,7 @@ dependencies:
shared_preferences: ^2.5.3
window_manager: ^0.5.1
auto_updater: 1.0.0
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:
@@ -73,7 +74,13 @@ flutter:
# To add assets to your application, add an assets section, like this:
assets:
- assets/background.png
- assets/launcher_key_art.png
- assets/login_key_art.png
- assets/logo.png
- assets/fonts/Cinzel-OFL.txt
- assets/fonts/CormorantGaramond-OFL.txt
- assets/fonts/Inter-OFL.txt
- assets/fonts/JetBrainsMono-OFL.txt
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
@@ -86,3 +93,12 @@ flutter:
- family: Cinzel
fonts:
- asset: assets/fonts/Cinzel.ttf
- family: Cormorant Garamond
fonts:
- asset: assets/fonts/CormorantGaramond.ttf
- family: Inter
fonts:
- asset: assets/fonts/Inter.ttf
- family: JetBrains Mono
fonts:
- asset: assets/fonts/JetBrainsMono.ttf
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/app/design_system/mw_authentication.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
void main() {
testWidgets('authentication placeholders use muted text color', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: Scaffold(
body: MwAuthenticationForm(
usernameController: TextEditingController(),
passwordController: TextEditingController(),
onSubmit: () {},
),
),
),
);
final fieldContext = tester.element(find.byType(TextField).first);
final hintColor = Theme.of(
fieldContext,
).inputDecorationTheme.hintStyle?.color;
expect(hintColor, const Color(0x69ECE4D0));
});
testWidgets('registration mode exposes all API registration fields', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: Scaffold(
body: SingleChildScrollView(
child: MwAuthenticationForm(
usernameController: TextEditingController(),
emailController: TextEditingController(),
passwordController: TextEditingController(),
passwordConfirmationController: TextEditingController(),
inviteCodeController: TextEditingController(),
mode: MwAuthenticationMode.registration,
termsAccepted: true,
onSubmit: () {},
),
),
),
),
);
expect(find.text('СОЗДАТЬ АККАУНТ'), findsOneWidget);
expect(find.text('EMAIL'), findsOneWidget);
expect(find.text('ПОВТОРИТЕ ПАРОЛЬ'), findsOneWidget);
expect(find.text('ИНВАЙТ-КОД (НЕОБЯЗАТЕЛЬНО)'), findsOneWidget);
expect(
find.text('Я принимаю правила сервера и пользовательское соглашение'),
findsOneWidget,
);
});
testWidgets('authentication tabs request mode changes', (tester) async {
MwAuthenticationMode? requestedMode;
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: Scaffold(
body: MwAuthenticationForm(
usernameController: TextEditingController(),
passwordController: TextEditingController(),
onModeChanged: (mode) => requestedMode = mode,
onSubmit: () {},
),
),
),
);
await tester.tap(find.text('РЕГИСТРАЦИЯ'));
expect(requestedMode, MwAuthenticationMode.registration);
});
}
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
void main() {
testWidgets('game rail shows WoW and MoonWell Karts', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: const Scaffold(
body: SizedBox(width: 240, child: MwGameRail(username: 'Sindoring')),
),
),
);
expect(find.text('MOONWELL · WOW'), findsOneWidget);
expect(find.text('OPEN BETA'), findsOneWidget);
expect(find.text('S'), findsOneWidget);
expect(find.text('MOONWELL KARTS'), findsOneWidget);
expect(find.text('В РАЗРАБОТКЕ'), findsOneWidget);
expect(find.text('SOON'), findsNothing);
expect(find.textContaining('TIDES OF ELUNE'), findsNothing);
expect(find.textContaining('PROJECT VERGE'), findsNothing);
});
testWidgets('game rail settings button invokes settings callback', (
tester,
) async {
var settingsRequested = false;
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: Scaffold(
body: SizedBox(
width: 240,
child: MwGameRail(onSettings: () => settingsRequested = true),
),
),
),
);
await tester.tap(find.byTooltip('Настройки'));
expect(settingsRequested, isTrue);
});
testWidgets('sync area displays realm data received from API', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: const Scaffold(
body: SizedBox(
width: 1000,
height: 150,
child: MwSyncActionArea(
state: MwSyncPresentation.ready,
status: 'Клиент готов.',
realmName: 'MoonWell',
realmOnline: true,
realmGameBuild: 12340,
onPrimary: _noop,
),
),
),
),
);
expect(find.text('MOONWELL'), findsOneWidget);
expect(find.text('ONLINE · BUILD 12340'), findsOneWidget);
});
testWidgets('news item invokes its link callback when pressed', (
tester,
) async {
var pressed = false;
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: Scaffold(
body: MwNewsItem(
item: MwNewsItemData(
title: 'Update 1.2',
body: 'News body',
onPressed: () => pressed = true,
),
),
),
),
);
await tester.tap(find.text('Update 1.2'));
expect(pressed, isTrue);
});
testWidgets('account affordance displays username without balance', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: Scaffold(
body: MwAccountAffordance(username: 'PLAYERONE', onLogout: _noop),
),
),
);
expect(find.text('PLAYERONE'), findsOneWidget);
expect(find.textContaining('1250'), findsNothing);
expect(find.text('P'), findsOneWidget);
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Выйти'), findsOneWidget);
expect(find.text('Настройки'), findsNothing);
});
testWidgets('settings panel excludes logout action', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: Scaffold(
body: MwSettingsPanel(
installationPath: r'C:\Games\MoonWell',
onChooseDirectory: _noop,
onVerify: _noop,
),
),
),
);
expect(find.text('Выбрать папку'), findsOneWidget);
expect(find.text('Проверить файлы'), findsOneWidget);
expect(find.text('Выйти'), findsNothing);
});
}
void _noop() {}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
void main() {
testWidgets('brand mark fits its requested width without overflow', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: const Scaffold(body: Center(child: MwBrandMark(width: 220))),
),
);
expect(tester.takeException(), isNull);
});
testWidgets('login shell renders the key art asset', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 720));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: MwLoginShell(
form: const SizedBox(),
onDrag: _noop,
onDoubleTap: _noop,
onMinimize: _noop,
onMaximizeRestore: _noop,
onClose: _noop,
),
),
);
await tester.pumpAndSettle();
final image = tester.widget<Image>(find.byType(Image));
expect((image.image as AssetImage).assetName, 'assets/login_key_art.png');
expect(tester.takeException(), isNull);
});
testWidgets('launcher shell renders the main key art asset', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 720));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: const MwLauncherShell(
news: SizedBox(),
syncActions: SizedBox(),
statusBar: SizedBox(),
account: SizedBox(),
onDrag: _noop,
onDoubleTap: _noop,
onMinimize: _noop,
onMaximizeRestore: _noop,
onClose: _noop,
),
),
);
await tester.pumpAndSettle();
final image = tester.widget<Image>(find.byType(Image));
expect(
(image.image as AssetImage).assetName,
'assets/launcher_key_art.png',
);
expect(tester.takeException(), isNull);
});
}
void _noop() {}
@@ -2,13 +2,17 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.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';
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/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.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';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
@@ -28,6 +32,19 @@ void main() {
createdAt: null,
),
],
realms: const [
LauncherRealm(
id: 1,
name: 'MoonWell',
address: 'logon.moon-well.online',
port: 8085,
population: 0.5,
gameBuild: 12340,
online: true,
status: 'online',
),
],
account: const LauncherAccount(username: 'PLAYERONE'),
),
preferencesRepository: _FakePreferencesRepository(),
session: LauncherSession(
@@ -46,12 +63,18 @@ void main() {
expect(bloc.state.model.newsErrorMessage, isNull);
expect(bloc.state.model.newsItems, hasLength(1));
expect(bloc.state.model.newsItems.first.title, 'Update 1.2');
expect(bloc.state.model.realm?.name, 'MoonWell');
expect(bloc.state.model.realm?.online, isTrue);
expect(bloc.state.model.accountUsername, 'PLAYERONE');
await bloc.close();
});
test('fetches a fresh manifest for every client sync', () async {
test('starts sync on startup when output directory is saved', () async {
final syncUseCase = _CapturingClientSyncUseCase();
final manifest = ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[],
});
final bloc = HomeScreenBloc(
clientSyncUseCase: syncUseCase,
gameInstallationService: _FakeGameInstallationService(),
@@ -64,17 +87,99 @@ void main() {
tokenType: 'Bearer',
expiresAt: null,
),
manifest: ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[],
}),
manifest: manifest,
);
await pumpEventQueue(times: 20);
bloc.add(HomeScreenSyncRequested());
expect(syncUseCase.inputs, hasLength(1));
expect(syncUseCase.inputs.single.request.manifest, same(manifest));
await bloc.close();
});
test('refreshes manifest and syncs when build changes', () async {
final startupManifest = ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[],
});
final updatedManifest = ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[
<String, Object?>{
'path': 'Wow.exe',
'size': 42,
'sha256':
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
},
],
});
final syncUseCase = _CapturingClientSyncUseCase();
final apiClient = _FakeLauncherApiClient(
newsItems: const [],
manifest: updatedManifest,
);
final bloc = HomeScreenBloc(
clientSyncUseCase: syncUseCase,
gameInstallationService: _FakeGameInstallationService(),
launcherApiClient: apiClient,
preferencesRepository: _FakePreferencesRepository(
outputDir: Uri.directory('C:/World of Warcraft'),
),
session: LauncherSession(
accessToken: 'token',
tokenType: 'Bearer',
expiresAt: null,
),
manifest: startupManifest,
);
await pumpEventQueue(times: 20);
bloc.add(HomeScreenManifestRefreshRequested());
await pumpEventQueue(times: 20);
expect(syncUseCase.lastInput, isNotNull);
expect(syncUseCase.lastInput!.request.manifest, isNull);
expect(apiClient.manifestRequestCount, 1);
expect(syncUseCase.inputs, hasLength(2));
expect(syncUseCase.inputs.last.request.manifest, same(updatedManifest));
await bloc.close();
});
test('returns to ready state after launched game exits', () async {
final manifest = ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[],
});
final gameService = _TrackingGameInstallationService();
final bloc = HomeScreenBloc(
clientSyncUseCase: _CapturingClientSyncUseCase(),
gameInstallationService: gameService,
launcherApiClient: _FakeLauncherApiClient(newsItems: const []),
preferencesRepository: _FakePreferencesRepository(
outputDir: Uri.directory('C:/World of Warcraft'),
),
session: LauncherSession(
accessToken: 'token',
tokenType: 'Bearer',
expiresAt: null,
),
manifest: manifest,
);
await pumpEventQueue(times: 20);
bloc.add(HomeScreenPlayRequested());
await pumpEventQueue(times: 20);
expect(gameService.launchCount, 1);
expect(bloc.state.model.isGameRunning, isTrue);
expect(bloc.state.model.canPlay, isFalse);
gameService.exitCode.complete(0);
await pumpEventQueue(times: 20);
expect(bloc.state.model.isGameRunning, isFalse);
expect(bloc.state.model.canPlay, isTrue);
expect(
bloc.state.model.statusText,
'Игра закрыта. Клиент готов к запуску.',
);
await bloc.close();
});
@@ -95,23 +200,59 @@ class _IdleClientSyncUseCase extends ClientSyncUseCase {
}
class _CapturingClientSyncUseCase extends _IdleClientSyncUseCase {
ClientSyncUseCaseInput? lastInput;
final List<ClientSyncUseCaseInput> inputs = [];
@override
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
lastInput = input;
return const Stream<ClientSyncStatus>.empty();
inputs.add(input);
final manifest = input.request.manifest;
return Stream<ClientSyncStatus>.value(
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: const DownloadProgress.initial(),
message: 'Клиент обновлён.',
currentPath: null,
localBuildHash: manifest?.buildHash,
remoteBuildHash: manifest?.buildHash,
processedFiles: manifest?.files.length ?? 0,
totalFiles: manifest?.files.length ?? 0,
),
);
}
}
class _FakeLauncherApiClient extends LauncherApiClient {
_FakeLauncherApiClient({required this.newsItems});
_FakeLauncherApiClient({
required this.newsItems,
this.manifest,
this.realms = const [],
this.account = const LauncherAccount(username: ''),
});
final List<LauncherNewsItem> newsItems;
final ClientManifest? manifest;
final List<LauncherRealm> realms;
final LauncherAccount account;
int manifestRequestCount = 0;
@override
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
newsItems;
@override
Future<List<LauncherRealm>> fetchRealms() async => realms;
@override
Future<LauncherAccount> fetchAccount(String accessToken) async => account;
@override
Future<ClientManifest> fetchManifest(String accessToken) async {
manifestRequestCount += 1;
return manifest ??
ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[],
});
}
}
class _FakeGameInstallationService extends GameInstallationService {
@@ -128,6 +269,23 @@ class _FakeGameInstallationService extends GameInstallationService {
}
}
class _TrackingGameInstallationService extends _FakeGameInstallationService {
final Completer<int> exitCode = Completer<int>();
int launchCount = 0;
@override
Future<bool> hasClientExecutable(String installationDir) async => true;
@override
Future<void> clearCache(String installationDir) async {}
@override
Future<GameProcessHandle> launchGame(String installationDir) async {
launchCount += 1;
return GameProcessHandle(pid: 42, exitCode: exitCode.future);
}
}
class _FakePreferencesRepository implements PreferencesRepository {
_FakePreferencesRepository({this.outputDir});
@@ -142,9 +300,16 @@ class _FakePreferencesRepository implements PreferencesRepository {
@override
Future<Uri?> getOutputDir() async => outputDir;
@override
Future<MoonWellThemeVariant> getThemeVariant() async =>
MoonWellThemeVariant.forest;
@override
Future<void> setLauncherSession(LauncherSession session) async {}
@override
Future<void> setOutputDir(Uri uri) async {}
@override
Future<void> setThemeVariant(MoonWellThemeVariant variant) async {}
}
@@ -64,7 +64,7 @@ void main() {
.toList();
expect(statuses.last.stage, ClientSyncStage.completed);
expect(statuses.last.message, 'Client is up to date.');
expect(statuses.last.message, 'Клиент обновлён.');
expect(api.downloadedPaths, isEmpty);
expect(installationService.deletedRelativeFiles, isEmpty);
expect(logger.errorMessages, isEmpty);
@@ -139,7 +139,7 @@ void main() {
);
expect(installationService.deletedRelativeFiles, ['legacy.txt']);
expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
expect(statuses.last.message, 'Client is ready to play.');
expect(statuses.last.message, 'Клиент готов к игре.');
expect(logger.errorMessages, isEmpty);
},
);
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/core/moonwell_theme_variant.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/launcher/domain/entities/client_manifest.dart';
@@ -107,6 +108,10 @@ class _FakePreferencesRepository implements PreferencesRepository {
@override
Future<Uri?> getOutputDir() async => null;
@override
Future<MoonWellThemeVariant> getThemeVariant() async =>
MoonWellThemeVariant.forest;
@override
Future<void> setLauncherSession(LauncherSession session) async {
this.session = session;
@@ -114,4 +119,7 @@ class _FakePreferencesRepository implements PreferencesRepository {
@override
Future<void> setOutputDir(Uri uri) async {}
@override
Future<void> setThemeVariant(MoonWellThemeVariant variant) async {}
}
@@ -0,0 +1,24 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_news_link.dart';
void main() {
test('builds public news URL from launcher base URL and news id', () {
expect(
buildLauncherNewsUri('http://localhost:8080', 4),
Uri.parse('http://localhost:8080/news/4'),
);
});
test('preserves a base URL path when building news URL', () {
expect(
buildLauncherNewsUri('https://example.com/moonwell', 7),
Uri.parse('https://example.com/moonwell/news/7'),
);
});
test('rejects invalid base URLs and news ids', () {
expect(buildLauncherNewsUri('', 4), isNull);
expect(buildLauncherNewsUri('not-a-url', 4), isNull);
expect(buildLauncherNewsUri('https://example.com', 0), isNull);
});
}
@@ -0,0 +1,13 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
void main() {
test('LauncherAccount parses username without exposing balance', () {
final account = LauncherAccount.fromJson(<String, Object?>{
'username': 'PLAYERONE',
'balance': '1250.00',
});
expect(account.username, 'PLAYERONE');
});
}
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
void main() {
test('LauncherRealm parses launcher realms payload', () {
final realm = LauncherRealm.fromJson(<String, Object?>{
'id': 1,
'name': 'MoonWell',
'address': 'logon.moon-well.online',
'port': 8085,
'population': 0.5,
'game_build': 12340,
'online': true,
'status': 'online',
});
expect(realm.id, 1);
expect(realm.name, 'MoonWell');
expect(realm.address, 'logon.moon-well.online');
expect(realm.port, 8085);
expect(realm.population, 0.5);
expect(realm.gameBuild, 12340);
expect(realm.online, isTrue);
expect(realm.status, 'online');
});
}
+669
View File
@@ -0,0 +1,669 @@
<#
.SYNOPSIS
Builds, signs, verifies, and deploys MoonWell Launcher to production.
.DESCRIPTION
Builds the Windows application and Inno Setup installer, verifies the DSA
signing key pair, signs the installer, generates appcast.xml, uploads the
installer to Yandex Object Storage, and publishes AppCast through the MoonWell
service API. Secrets are read only from environment variables.
.PARAMETER ExpectedVersion
Fails unless the X.Y.Z part of pubspec.yaml matches this value.
.PARAMETER ReleaseNotes
Text written to the AppCast item description.
.PARAMETER DryRun
Builds, signs, and verifies local artifacts without changing production.
.PARAMETER Force
Allows redeploying a version that is not newer than the public AppCast.
.PARAMETER SkipChecks
Skips pub get, formatting, static analysis, and tests.
.EXAMPLE
.\tool\deploy_launcher.ps1 -DryRun -ExpectedVersion 1.0.2 `
-ReleaseNotes "MoonWell Launcher improvements."
.EXAMPLE
.\tool\deploy_launcher.ps1 -ExpectedVersion 1.0.2 `
-ReleaseNotes "MoonWell Launcher improvements."
#>
#requires -Version 5.1
[CmdletBinding()]
param(
[string]$ExpectedVersion,
[string]$ReleaseNotes = 'MoonWell Launcher update.',
[string]$ApiBaseUrl = 'https://moon-well.online',
[string]$AppcastUrl = 'https://moon-well.online/appcast.xml',
[string]$S3Endpoint,
[string]$S3Bucket,
[string]$S3Key = 'moonwell_launcher_setup.exe',
[string]$PrivateKeyPath = '.moonwell_signing\dsa_priv.pem',
[string]$PublicKeyPath = 'windows\runner\resources\dsa_pub.pem',
[string]$FlutterCommand = 'flutter',
[string]$DartCommand = 'dart',
[string]$InnoSetupCommand,
[string]$OpenSslCommand,
[switch]$SkipChecks,
[switch]$DryRun,
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$script:SparkleNamespace =
'http://www.andymatuschak.org/xml-namespaces/sparkle'
function Write-Step {
param([Parameter(Mandatory = $true)][string]$Message)
Write-Host "`n==> $Message" -ForegroundColor Cyan
}
function Resolve-RequiredCommand {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string]$Description
)
if (Test-Path -LiteralPath $Command -PathType Leaf) {
return (Resolve-Path -LiteralPath $Command).Path
}
$resolved = Get-Command $Command -ErrorAction SilentlyContinue
if ($null -eq $resolved) {
throw "$Description was not found: $Command"
}
return $resolved.Source
}
function Invoke-RequiredCommand {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Command failed with exit code ${LASTEXITCODE}: $Command"
}
}
function Get-FileSha256 {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Get-AppcastVersion {
param([Parameter(Mandatory = $true)][string]$Path)
[xml]$xml = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
$enclosure = $xml.rss.channel.item.enclosure
if ($null -eq $enclosure) {
throw "AppCast does not contain channel/item/enclosure: $Path"
}
return $enclosure.GetAttribute('version', $script:SparkleNamespace)
}
function Write-Appcast {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$Notes,
[Parameter(Mandatory = $true)][string]$InstallerUrl,
[Parameter(Mandatory = $true)][string]$Signature,
[Parameter(Mandatory = $true)][long]$Length
)
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($false)
$settings.Indent = $true
$settings.IndentChars = ' '
$settings.NewLineChars = "`n"
$settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace
$writer = [System.Xml.XmlWriter]::Create($Path, $settings)
try {
$writer.WriteStartDocument()
$writer.WriteStartElement('rss')
$writer.WriteAttributeString('version', '2.0')
$writer.WriteAttributeString(
'xmlns',
'sparkle',
'http://www.w3.org/2000/xmlns/',
$script:SparkleNamespace
)
$writer.WriteStartElement('channel')
$writer.WriteElementString('title', 'MoonWell Launcher')
$writer.WriteElementString(
'description',
'MoonWell Launcher updates'
)
$writer.WriteElementString('language', 'ru')
$writer.WriteStartElement('item')
$writer.WriteElementString(
'title',
"MoonWell Launcher $Version"
)
$writer.WriteElementString('description', $Notes)
$writer.WriteElementString(
'pubDate',
[DateTimeOffset]::Now.ToString(
'ddd, dd MMM yyyy HH:mm:ss zzz',
[Globalization.CultureInfo]::InvariantCulture
)
)
$writer.WriteStartElement('enclosure')
$writer.WriteAttributeString('url', $InstallerUrl)
$writer.WriteAttributeString(
'sparkle',
'version',
$script:SparkleNamespace,
$Version
)
$writer.WriteAttributeString(
'sparkle',
'shortVersionString',
$script:SparkleNamespace,
$Version
)
$writer.WriteAttributeString(
'sparkle',
'os',
$script:SparkleNamespace,
'windows'
)
$writer.WriteAttributeString(
'sparkle',
'dsaSignature',
$script:SparkleNamespace,
$Signature
)
$writer.WriteAttributeString('length', $Length.ToString())
$writer.WriteAttributeString(
'type',
'application/octet-stream'
)
$writer.WriteEndElement()
$writer.WriteEndElement()
$writer.WriteEndElement()
$writer.WriteEndElement()
$writer.WriteEndDocument()
}
finally {
$writer.Dispose()
}
}
function Assert-SigningKeyPair {
param(
[Parameter(Mandatory = $true)][string]$OpenSsl,
[Parameter(Mandatory = $true)][string]$PrivateKey,
[Parameter(Mandatory = $true)][string]$PublicKey,
[Parameter(Mandatory = $true)][string]$TemporaryPublicKey
)
Invoke-RequiredCommand -Command $OpenSsl -Arguments @(
'dsa',
'-in',
$PrivateKey,
'-pubout',
'-out',
$TemporaryPublicKey
)
$expected = (
Get-Content -LiteralPath $PublicKey -Raw
) -replace '\s', ''
$actual = (
Get-Content -LiteralPath $TemporaryPublicKey -Raw
) -replace '\s', ''
if ($expected -cne $actual) {
throw 'The private DSA key does not match the launcher public key.'
}
}
function Assert-InstallerSignature {
param(
[Parameter(Mandatory = $true)][string]$OpenSsl,
[Parameter(Mandatory = $true)][string]$InstallerPath,
[Parameter(Mandatory = $true)][string]$PublicKey,
[Parameter(Mandatory = $true)][string]$Signature,
[Parameter(Mandatory = $true)][string]$TemporaryDirectory
)
$signaturePath = Join-Path $TemporaryDirectory 'installer.sig'
$firstDigestPath = Join-Path $TemporaryDirectory 'installer.sha1'
[IO.File]::WriteAllBytes(
$signaturePath,
[Convert]::FromBase64String($Signature)
)
$stream = [IO.File]::OpenRead($InstallerPath)
try {
$sha1 = [Security.Cryptography.SHA1]::Create()
try {
[IO.File]::WriteAllBytes(
$firstDigestPath,
$sha1.ComputeHash($stream)
)
}
finally {
$sha1.Dispose()
}
}
finally {
$stream.Dispose()
}
Invoke-RequiredCommand -Command $OpenSsl -Arguments @(
'dgst',
'-sha1',
'-verify',
$PublicKey,
'-signature',
$signaturePath,
$firstDigestPath
)
}
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$previousLocation = Get-Location
try {
Set-Location $repositoryRoot
if ([string]::IsNullOrWhiteSpace($S3Endpoint)) {
$S3Endpoint = $env:AWS_ENDPOINT
}
if ([string]::IsNullOrWhiteSpace($S3Endpoint)) {
$S3Endpoint = 'https://storage.yandexcloud.net'
}
if ([string]::IsNullOrWhiteSpace($S3Bucket)) {
$S3Bucket = $env:AWS_BUCKET
}
if ([string]::IsNullOrWhiteSpace($S3Bucket)) {
$S3Bucket = 'warcraft-client'
}
foreach ($url in @($ApiBaseUrl, $AppcastUrl, $S3Endpoint)) {
$uri = $null
if (
-not [Uri]::TryCreate(
$url,
[UriKind]::Absolute,
[ref]$uri
) -or
$uri.Scheme -ne 'https'
) {
throw "Production URL must be absolute HTTPS: $url"
}
}
$python = $null
if (-not $DryRun) {
foreach ($name in @(
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'LAUNCHER_AUTH_KEY'
)) {
$value = [Environment]::GetEnvironmentVariable($name)
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Required environment variable $name is not set."
}
}
$python = Resolve-RequiredCommand `
-Command 'python' `
-Description 'Python'
Invoke-RequiredCommand -Command $python -Arguments @(
'-c',
'import boto3'
)
}
$versionMatch = [regex]::Match(
(Get-Content -LiteralPath 'pubspec.yaml' -Raw),
'(?m)^version:\s*(\d+\.\d+\.\d+)\+(\d+)\s*$'
)
if (-not $versionMatch.Success) {
throw 'The pubspec.yaml version must use the X.Y.Z+N format.'
}
$version = $versionMatch.Groups[1].Value
$buildNumber = $versionMatch.Groups[2].Value
if (
-not [string]::IsNullOrWhiteSpace($ExpectedVersion) -and
$ExpectedVersion -ne $version
) {
throw "Expected version $ExpectedVersion, pubspec.yaml contains $version."
}
$privateKey = (Resolve-Path -LiteralPath $PrivateKeyPath).Path
$publicKey = (Resolve-Path -LiteralPath $PublicKeyPath).Path
$flutter = Resolve-RequiredCommand `
-Command $FlutterCommand `
-Description 'Flutter SDK'
$dart = Resolve-RequiredCommand `
-Command $DartCommand `
-Description 'Dart SDK'
if ([string]::IsNullOrWhiteSpace($InnoSetupCommand)) {
$InnoSetupCommand =
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe'
}
$innoSetup = Resolve-RequiredCommand `
-Command $InnoSetupCommand `
-Description 'Inno Setup Compiler'
if ([string]::IsNullOrWhiteSpace($OpenSslCommand)) {
$gitOpenSsl = 'C:\Program Files\Git\usr\bin\openssl.exe'
$OpenSslCommand = if (Test-Path -LiteralPath $gitOpenSsl) {
$gitOpenSsl
}
else {
'openssl'
}
}
$openSsl = Resolve-RequiredCommand `
-Command $OpenSslCommand `
-Description 'OpenSSL'
$temporaryDirectory = Join-Path $repositoryRoot 'build\launcher_release'
New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null
Write-Step "Checking DSA keys for version $version+$buildNumber"
Assert-SigningKeyPair `
-OpenSsl $openSsl `
-PrivateKey $privateKey `
-PublicKey $publicKey `
-TemporaryPublicKey (
Join-Path $temporaryDirectory 'derived_dsa_pub.pem'
)
if (-not $DryRun -and -not $Force) {
Write-Step 'Checking the published version'
try {
$publishedPath = Join-Path $temporaryDirectory 'current_appcast.xml'
Invoke-WebRequest `
-Uri $AppcastUrl `
-UseBasicParsing `
-OutFile $publishedPath
$publishedVersion = Get-AppcastVersion -Path $publishedPath
if (
-not [string]::IsNullOrWhiteSpace($publishedVersion) -and
[version]$version -le [version]$publishedVersion
) {
throw (
"Version $version is not newer than published version " +
"$publishedVersion. Increase X.Y.Z or use -Force."
)
}
}
catch {
if ($_.Exception.Message -like 'Version *') {
throw
}
Write-Warning (
'Could not determine the currently published version: ' +
$_.Exception.Message
)
}
}
if (-not $SkipChecks) {
Write-Step 'Dependencies, formatting, analysis, and tests'
Invoke-RequiredCommand -Command $flutter -Arguments @('pub', 'get')
Invoke-RequiredCommand -Command $dart -Arguments @(
'format',
'--output=none',
'--set-exit-if-changed',
'lib',
'test'
)
Invoke-RequiredCommand -Command $flutter -Arguments @(
'analyze',
'lib',
'test'
)
Invoke-RequiredCommand -Command $flutter -Arguments @('test')
}
Write-Step 'Building the Windows production application'
Invoke-RequiredCommand -Command $flutter -Arguments @(
'build',
'windows',
'--release',
"--dart-define=MOONWELL_API_BASE_URL=$ApiBaseUrl",
"--dart-define=MOONWELL_APPCAST_URL=$AppcastUrl"
)
Write-Step 'Building the Inno Setup installer'
Invoke-RequiredCommand -Command $innoSetup -Arguments @(
"/DMyAppVersion=$version",
'installer\moonwell_launcher.iss'
)
$installerPath = Join-Path (
Join-Path $repositoryRoot 'build\installer'
) "moonwell_launcher_${version}_windows_setup.exe"
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) {
throw "The installer was not created: $installerPath"
}
Write-Step 'Signing the installer with DSA'
$gitOpenSslDirectory = Split-Path $openSsl -Parent
$previousPath = $env:PATH
try {
$env:PATH = "$gitOpenSslDirectory;$env:PATH"
$signOutput = & $dart run auto_updater:sign_update `
$installerPath `
$privateKey 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Could not sign the installer: $signOutput"
}
}
finally {
$env:PATH = $previousPath
}
$signatureMatch = [regex]::Match(
($signOutput -join "`n"),
'sparkle:dsaSignature="([^"]+)"',
[Text.RegularExpressions.RegexOptions]::Singleline
)
if (-not $signatureMatch.Success) {
throw 'The signing tool did not return sparkle:dsaSignature.'
}
$signature = $signatureMatch.Groups[1].Value -replace '\s', ''
[void][Convert]::FromBase64String($signature)
$installer = Get-Item -LiteralPath $installerPath
$installerSha256 = Get-FileSha256 -Path $installerPath
Assert-InstallerSignature `
-OpenSsl $openSsl `
-InstallerPath $installerPath `
-PublicKey $publicKey `
-Signature $signature `
-TemporaryDirectory $temporaryDirectory
$installerUrl = (
$S3Endpoint.TrimEnd('/') +
'/' +
$S3Bucket +
'/' +
$S3Key
)
$appcastPath = if ($DryRun) {
Join-Path $temporaryDirectory 'appcast.xml'
}
else {
Join-Path $repositoryRoot 'appcast.xml'
}
Write-Step 'Generating appcast.xml'
Write-Appcast `
-Path $appcastPath `
-Version $version `
-Notes $ReleaseNotes `
-InstallerUrl $installerUrl `
-Signature $signature `
-Length $installer.Length
[xml]$generatedAppcast = Get-Content `
-LiteralPath $appcastPath `
-Raw `
-Encoding UTF8
if ((Get-AppcastVersion -Path $appcastPath) -ne $version) {
throw 'The generated AppCast contains the wrong version.'
}
if ($DryRun) {
Write-Step 'Dry run complete - production was not changed'
Write-Host "Version: $version+$buildNumber"
Write-Host "Installer: $installerPath"
Write-Host "Size: $($installer.Length)"
Write-Host "SHA-256: $installerSha256"
Write-Host "AppCast: $appcastPath"
return
}
Write-Step "Uploading $S3Bucket/$S3Key"
$env:MOONWELL_RELEASE_FILE = $installerPath
$env:MOONWELL_RELEASE_ENDPOINT = $S3Endpoint
$env:MOONWELL_RELEASE_BUCKET = $S3Bucket
$env:MOONWELL_RELEASE_KEY = $S3Key
$env:MOONWELL_RELEASE_VERSION = $version
$env:MOONWELL_RELEASE_SHA256 = $installerSha256
$uploadScript = @'
import os
import boto3
from botocore.config import Config
path_style = os.getenv("AWS_USE_PATH_STYLE_ENDPOINT", "true").lower()
addressing_style = "path" if path_style in ("1", "true", "yes") else "virtual"
s3 = boto3.client(
"s3",
endpoint_url=os.environ["MOONWELL_RELEASE_ENDPOINT"],
region_name=os.getenv("AWS_DEFAULT_REGION", "ru-central1"),
config=Config(s3={"addressing_style": addressing_style}),
)
s3.upload_file(
os.environ["MOONWELL_RELEASE_FILE"],
os.environ["MOONWELL_RELEASE_BUCKET"],
os.environ["MOONWELL_RELEASE_KEY"],
ExtraArgs={
"ACL": "public-read",
"ContentType": "application/x-msdownload",
"CacheControl": "no-cache, max-age=0",
"Metadata": {
"launcher-version": os.environ["MOONWELL_RELEASE_VERSION"],
"sha256": os.environ["MOONWELL_RELEASE_SHA256"],
},
},
)
response = s3.head_object(
Bucket=os.environ["MOONWELL_RELEASE_BUCKET"],
Key=os.environ["MOONWELL_RELEASE_KEY"],
)
print("S3_VERSION_ID=" + str(response.get("VersionId", "")))
print("S3_CONTENT_LENGTH=" + str(response["ContentLength"]))
'@
try {
Invoke-RequiredCommand -Command $python -Arguments @(
'-c',
$uploadScript
)
}
finally {
Remove-Item Env:MOONWELL_RELEASE_FILE -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_ENDPOINT -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_BUCKET -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_KEY -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_SHA256 -ErrorAction SilentlyContinue
}
Write-Step 'Verifying the uploaded installer'
$downloadedInstaller = Join-Path `
$temporaryDirectory `
'published_installer.exe'
Invoke-WebRequest `
-Uri $installerUrl `
-UseBasicParsing `
-OutFile $downloadedInstaller
$publishedSha256 = Get-FileSha256 -Path $downloadedInstaller
if ($publishedSha256 -ne $installerSha256) {
throw (
'The uploaded installer SHA-256 does not match the local file: ' +
"$publishedSha256"
)
}
Write-Step 'Publishing AppCast'
$publishResponse = Invoke-WebRequest `
-Uri "$($ApiBaseUrl.TrimEnd('/'))/api/service/update-appcast" `
-Method Post `
-Headers @{
Auth = $env:LAUNCHER_AUTH_KEY
Accept = 'application/json'
} `
-ContentType 'application/xml' `
-Body ([IO.File]::ReadAllBytes($appcastPath)) `
-UseBasicParsing
if ($publishResponse.StatusCode -lt 200 -or $publishResponse.StatusCode -ge 300) {
throw "AppCast publication returned HTTP $($publishResponse.StatusCode)."
}
Write-Step 'Verifying the published AppCast'
$publishedAppcastPath = Join-Path `
$temporaryDirectory `
'published_appcast.xml'
$publicResponse = Invoke-WebRequest `
-Uri $AppcastUrl `
-UseBasicParsing `
-OutFile $publishedAppcastPath `
-PassThru
if ($publicResponse.StatusCode -ne 200) {
throw "The public AppCast returned HTTP $($publicResponse.StatusCode)."
}
if (
(Get-FileSha256 -Path $publishedAppcastPath) -ne
(Get-FileSha256 -Path $appcastPath)
) {
throw 'The published AppCast does not match the local file.'
}
[xml]$publishedAppcast = Get-Content `
-LiteralPath $publishedAppcastPath `
-Raw `
-Encoding UTF8
$publishedEnclosure = $publishedAppcast.rss.channel.item.enclosure
if (
$publishedEnclosure.GetAttribute(
'version',
$script:SparkleNamespace
) -ne $version -or
[long]$publishedEnclosure.length -ne $installer.Length -or
$publishedEnclosure.url -ne $installerUrl
) {
throw 'The published AppCast contains incorrect release data.'
}
Write-Step 'Production deployment complete'
Write-Host "Version: $version+$buildNumber"
Write-Host "Installer: $installerUrl"
Write-Host "SHA-256: $installerSha256"
Write-Host "AppCast: $AppcastUrl"
}
finally {
Set-Location $previousLocation
}
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "stable"
}
+48
View File
@@ -0,0 +1,48 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# FVM Version Cache
.fvm/
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
- platform: macos
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
- platform: windows
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+3
View File
@@ -0,0 +1,3 @@
{
"dart.flutterSdkPath": ".fvm/versions/stable"
}
+11
View File
@@ -0,0 +1,11 @@
# MoonWell Widgetbook
Standalone development catalog for MoonWell Launcher components.
```powershell
fvm flutter pub get
fvm dart run build_runner build
fvm flutter run -d windows
```
Use cases must follow [`../docs/widgetbook.md`](../docs/widgetbook.md).
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
import 'main.directories.g.dart';
void main() => runApp(const MoonWellWidgetbook());
@widgetbook.App()
class MoonWellWidgetbook extends StatelessWidget {
const MoonWellWidgetbook({super.key});
@override
Widget build(BuildContext context) {
return Widgetbook.material(
initialRoute: '/?path=compositions/mwlaunchershell/ready',
directories: directories,
addons: [
MaterialThemeAddon(
themes: [
WidgetbookTheme(
name: 'Forest',
data: MoonWellTheme.create(
MoonWellThemeVariant.forest,
fontPackage: 'moonwell_launcher',
),
),
WidgetbookTheme(
name: 'Temple',
data: MoonWellTheme.create(
MoonWellThemeVariant.temple,
fontPackage: 'moonwell_launcher',
),
),
],
),
ViewportAddon(const [
ViewportData(
name: 'Minimum desktop · 1024×640',
width: 1024,
height: 640,
pixelRatio: 1,
platform: TargetPlatform.windows,
),
ViewportData(
name: 'Target desktop · 1280×720',
width: 1280,
height: 720,
pixelRatio: 1,
platform: TargetPlatform.windows,
),
ViewportData(
name: 'Large desktop · 1600×900',
width: 1600,
height: 900,
pixelRatio: 1,
platform: TargetPlatform.windows,
),
]),
TextScaleAddon(min: 1, max: 2, divisions: 4, initialScale: 1),
],
);
}
}
+413
View File
@@ -0,0 +1,413 @@
// dart format width=80
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_import, prefer_relative_imports, directives_ordering
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
// AppGenerator
// **************************************************************************
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:widgetbook/widgetbook.dart' as _widgetbook;
import 'package:widgetbook_workspace/stories/authentication_stories.dart'
as _widgetbook_workspace_stories_authentication_stories;
import 'package:widgetbook_workspace/stories/foundations_stories.dart'
as _widgetbook_workspace_stories_foundations_stories;
import 'package:widgetbook_workspace/stories/launcher_stories.dart'
as _widgetbook_workspace_stories_launcher_stories;
import 'package:widgetbook_workspace/stories/primitives_stories.dart'
as _widgetbook_workspace_stories_primitives_stories;
import 'package:widgetbook_workspace/stories/shell_stories.dart'
as _widgetbook_workspace_stories_shell_stories;
import 'package:widgetbook_workspace/stories/window_chrome_stories.dart'
as _widgetbook_workspace_stories_window_chrome_stories;
final directories = <_widgetbook.WidgetbookNode>[
_widgetbook.WidgetbookCategory(
name: 'Authentication',
children: [
_widgetbook.WidgetbookComponent(
name: 'MwAuthenticationForm',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'api_error',
builder: _widgetbook_workspace_stories_authentication_stories
.buildMwAuthenticationFormApiErrorUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'default',
builder: _widgetbook_workspace_stories_authentication_stories
.buildMwAuthenticationFormUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'loading',
builder: _widgetbook_workspace_stories_authentication_stories
.buildMwAuthenticationFormLoadingUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'long_russian_copy',
builder: _widgetbook_workspace_stories_authentication_stories
.buildMwAuthenticationFormLongRussianCopyUseCase,
),
],
),
],
),
_widgetbook.WidgetbookCategory(
name: 'Compositions',
children: [
_widgetbook.WidgetbookComponent(
name: 'MwLauncherShell',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'downloading',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwLauncherShellDownloadingUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'empty_news',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwLauncherShellEmptyNewsUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'failure',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwLauncherShellFailureUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'paused',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwLauncherShellPausedUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'ready',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwLauncherShellReadyUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'verifying',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwLauncherShellVerifyingUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwLoginShell',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'api_error',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwLoginShellApiErrorUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'default',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwLoginShellUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'restored_session_loading',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwLoginShellRestoredSessionLoadingUseCase,
),
],
),
],
),
_widgetbook.WidgetbookCategory(
name: 'Foundations',
children: [
_widgetbook.WidgetbookComponent(
name: 'ArtworkSlot',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'default',
builder: _widgetbook_workspace_stories_foundations_stories
.buildArtworkSlotUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'FoundationsGallery',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'default',
builder: _widgetbook_workspace_stories_foundations_stories
.buildFoundationsGalleryUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwArtworkBackground',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'default',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwArtworkBackgroundUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwBrandMark',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'default',
builder: _widgetbook_workspace_stories_shell_stories
.buildMwBrandMarkUseCase,
),
],
),
],
),
_widgetbook.WidgetbookCategory(
name: 'Launcher',
children: [
_widgetbook.WidgetbookComponent(
name: 'MwAccountAffordance',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'default',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwAccountAffordanceUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwGameRail',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'compact',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwGameRailCompactUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'full',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwGameRailFullUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwGameTile',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwGameTileInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwLauncherStatusBar',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'metadata',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwLauncherStatusBarMetadataUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwNewsItem',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'image_failure',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwNewsItemImageFailureUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'text_only',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwNewsItemTextOnlyUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwNewsList',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'empty',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwNewsListEmptyUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'failure',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwNewsListFailureUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'loading',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwNewsListLoadingUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'populated',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwNewsListPopulatedUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwSettingsPanel',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwSettingsPanelInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwSyncActionArea',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_launcher_stories
.buildMwSyncActionAreaInteractiveUseCase,
),
],
),
],
),
_widgetbook.WidgetbookCategory(
name: 'Primitives',
children: [
_widgetbook.WidgetbookComponent(
name: 'MwBadge',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwBadgeInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwButton',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'disabled',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwButtonDisabledUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwButtonInteractiveUseCase,
),
_widgetbook.WidgetbookUseCase(
name: 'loading',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwButtonLoadingUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwCheckbox',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwCheckboxInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwDisabledFeature',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'default',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwDisabledFeatureUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwIconButton',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwIconButtonInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwPanel',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwPanelInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwProgressBar',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwProgressBarInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwStateView',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwStateViewInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwStatusText',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwStatusTextInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwTabs',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwTabsInteractiveUseCase,
),
],
),
_widgetbook.WidgetbookComponent(
name: 'MwTextField',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_primitives_stories
.buildMwTextFieldInteractiveUseCase,
),
],
),
],
),
_widgetbook.WidgetbookCategory(
name: 'Window Chrome',
children: [
_widgetbook.WidgetbookComponent(
name: 'MwWindowChrome',
useCases: [
_widgetbook.WidgetbookUseCase(
name: 'interactive',
builder: _widgetbook_workspace_stories_window_chrome_stories
.buildMwWindowChromeInteractiveUseCase,
),
],
),
],
),
];
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_authentication.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(
name: 'default',
type: MwAuthenticationForm,
path: '[Authentication]',
)
Widget buildMwAuthenticationFormUseCase(BuildContext context) {
return SizedBox(
width: 420,
child: MwAuthenticationForm(
usernameController: TextEditingController(
text: context.knobs.string(label: 'username', initialValue: ''),
),
passwordController: TextEditingController(
text: context.knobs.string(label: 'password', initialValue: ''),
),
onSubmit: () => debugPrint('Authentication form submitted'),
),
);
}
@widgetbook.UseCase(
name: 'loading',
type: MwAuthenticationForm,
path: '[Authentication]',
)
Widget buildMwAuthenticationFormLoadingUseCase(BuildContext context) {
return SizedBox(
width: 420,
child: MwAuthenticationForm(
usernameController: TextEditingController(text: 'moonwalker'),
passwordController: TextEditingController(text: 'password'),
loading: true,
onSubmit: () => debugPrint('Loading authentication form submitted'),
),
);
}
@widgetbook.UseCase(
name: 'api_error',
type: MwAuthenticationForm,
path: '[Authentication]',
)
Widget buildMwAuthenticationFormApiErrorUseCase(BuildContext context) {
return SizedBox(
width: 420,
child: MwAuthenticationForm(
usernameController: TextEditingController(text: 'moonwalker'),
passwordController: TextEditingController(),
error: context.knobs.string(
label: 'error',
initialValue: 'Неверный логин или пароль.',
),
onSubmit: () => debugPrint('Errored authentication form submitted'),
),
);
}
@widgetbook.UseCase(
name: 'long_russian_copy',
type: MwAuthenticationForm,
path: '[Authentication]',
)
Widget buildMwAuthenticationFormLongRussianCopyUseCase(BuildContext context) {
return SizedBox(
width: 420,
child: MwAuthenticationForm(
usernameController: TextEditingController(),
passwordController: TextEditingController(),
error:
'Не удалось связаться с сервером авторизации. Проверьте подключение '
'к интернету и повторите попытку через несколько минут.',
onSubmit: () => debugPrint('Long-copy authentication form submitted'),
),
);
}
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_primitives.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
class FoundationsGallery extends StatelessWidget {
const FoundationsGallery({super.key});
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final colors = <String, Color>{
'Background': tokens.background,
'Surface low': tokens.surfaceLow,
'Surface': tokens.surface,
'Surface high': tokens.surfaceHigh,
'Accent': tokens.accent,
'Accent muted': tokens.accentMuted,
'Success': tokens.success,
'Warning': tokens.warning,
'Error': Theme.of(context).colorScheme.error,
};
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Semantic colors',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final entry in colors.entries)
SizedBox(
width: 144,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 72,
decoration: BoxDecoration(
color: entry.value,
border: Border.all(color: Colors.white24),
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 6),
Text(entry.key),
MwStatusText(
text:
'#${entry.value.toARGB32().toRadixString(16).substring(2).toUpperCase()}',
monospace: true,
),
],
),
),
],
),
const SizedBox(height: 32),
Text('Typography', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Text(
'MoonWell',
style: TextStyle(
fontFamily: tokens.brandFontFamily,
fontSize: 32,
letterSpacing: 2,
),
),
Text(
'Лунный колодец зовёт героев Нордскола',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('Интерфейс, формы, навигация и рабочие статусы'),
MwStatusText(
text: 'build a93fd1c2 · 12.4 MB/s · 00:01:42',
monospace: true,
),
const SizedBox(height: 32),
Text(
'Surfaces & radii',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
const Row(
children: [
Expanded(
child: MwPanel(level: MwPanelLevel.low, child: Text('Low')),
),
SizedBox(width: 12),
Expanded(child: MwPanel(child: Text('Regular'))),
SizedBox(width: 12),
Expanded(
child: MwPanel(level: MwPanelLevel.high, child: Text('High')),
),
],
),
],
),
);
}
}
@widgetbook.UseCase(
name: 'default',
type: FoundationsGallery,
path: '[Foundations]',
)
Widget buildFoundationsGalleryUseCase(BuildContext context) {
return const FoundationsGallery();
}
class ArtworkSlot extends StatelessWidget {
const ArtworkSlot({super.key});
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
'assets/background.png',
package: 'moonwell_launcher',
fit: BoxFit.cover,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Color(0x22000000), Color(0xEE000000)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
const Center(child: MwBrandMark(width: 240)),
],
);
}
}
@widgetbook.UseCase(name: 'default', type: ArtworkSlot, path: '[Foundations]')
Widget buildArtworkSlotUseCase(BuildContext context) {
return const ArtworkSlot();
}
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(name: 'interactive', type: MwGameTile, path: '[Launcher]')
Widget buildMwGameTileInteractiveUseCase(BuildContext context) {
return SizedBox(
width: context.knobs.double.slider(
label: 'width',
initialValue: 240,
min: 72,
max: 360,
divisions: 36,
),
child: MwGameTile(
title: context.knobs.string(label: 'title', initialValue: 'MoonWell'),
subtitle: context.knobs.stringOrNull(
label: 'subtitle',
initialValue: 'Wrath of the Lich King',
),
icon: Icons.nights_stay_outlined,
selected: context.knobs.boolean(label: 'selected', initialValue: true),
enabled: context.knobs.boolean(label: 'enabled', initialValue: true),
compact: context.knobs.boolean(label: 'compact', initialValue: false),
onPressed: () => debugPrint('Game tile selected'),
),
);
}
@widgetbook.UseCase(name: 'full', type: MwGameRail, path: '[Launcher]')
Widget buildMwGameRailFullUseCase(BuildContext context) {
return const SizedBox(
width: 240,
height: 560,
child: MwGameRail(username: 'Aranthel'),
);
}
@widgetbook.UseCase(name: 'compact', type: MwGameRail, path: '[Launcher]')
Widget buildMwGameRailCompactUseCase(BuildContext context) {
return const SizedBox(
width: 72,
height: 560,
child: MwGameRail(compact: true, username: 'Aranthel'),
);
}
@widgetbook.UseCase(name: 'text_only', type: MwNewsItem, path: '[Launcher]')
Widget buildMwNewsItemTextOnlyUseCase(BuildContext context) {
return SizedBox(
width: 620,
child: MwNewsItem(
item: MwNewsItemData(
title: context.knobs.string(
label: 'title',
initialValue: 'Добро пожаловать в MoonWell',
),
body: context.knobs.string(
label: 'body',
initialValue: 'Приключение в Нордсколе начинается здесь.',
),
createdAt: DateTime(2026, 6, 22),
),
),
);
}
@widgetbook.UseCase(name: 'image_failure', type: MwNewsItem, path: '[Launcher]')
Widget buildMwNewsItemImageFailureUseCase(BuildContext context) {
return const SizedBox(
width: 620,
child: MwNewsItem(
item: MwNewsItemData(
title: 'Обновление игрового мира',
body: 'Изображение недоступно, но публикация остаётся читаемой.',
imageUrl: 'https://invalid.moonwell.local/news.jpg',
),
),
);
}
@widgetbook.UseCase(name: 'loading', type: MwNewsList, path: '[Launcher]')
Widget buildMwNewsListLoadingUseCase(BuildContext context) {
return const MwNewsList(state: MwNewsListState.loading);
}
@widgetbook.UseCase(name: 'empty', type: MwNewsList, path: '[Launcher]')
Widget buildMwNewsListEmptyUseCase(BuildContext context) {
return const MwNewsList(state: MwNewsListState.empty);
}
@widgetbook.UseCase(name: 'failure', type: MwNewsList, path: '[Launcher]')
Widget buildMwNewsListFailureUseCase(BuildContext context) {
return MwNewsList(
state: MwNewsListState.failure,
error: context.knobs.string(
label: 'error',
initialValue: 'Сервер новостей временно недоступен.',
),
onRetry: () => debugPrint('News reload requested'),
);
}
@widgetbook.UseCase(name: 'populated', type: MwNewsList, path: '[Launcher]')
Widget buildMwNewsListPopulatedUseCase(BuildContext context) {
final count = context.knobs.int.slider(
label: 'itemCount',
initialValue: 8,
min: 1,
max: 50,
divisions: 49,
);
return MwNewsList(
state: MwNewsListState.populated,
items: List.generate(
count,
(index) => MwNewsItemData(
title: 'Новость ${index + 1}',
body:
'Реалистичный текст публикации о событиях и обновлениях MoonWell.',
createdAt: DateTime(2026, 6, 22).subtract(Duration(days: index)),
),
),
);
}
@widgetbook.UseCase(
name: 'interactive',
type: MwSyncActionArea,
path: '[Launcher]',
)
Widget buildMwSyncActionAreaInteractiveUseCase(BuildContext context) {
final state = context.knobs.object.dropdown<MwSyncPresentation>(
label: 'state',
initialOption: MwSyncPresentation.downloading,
options: MwSyncPresentation.values,
labelBuilder: (value) => value.name,
);
return SizedBox(
width: 420,
child: MwSyncActionArea(
state: state,
status: context.knobs.string(
label: 'status',
initialValue: 'Загружаем обновление клиента…',
),
progress: context.knobs.double.slider(
label: 'progress',
initialValue: .46,
min: 0,
max: 1,
divisions: 100,
),
currentPath: context.knobs.stringOrNull(
label: 'currentPath',
initialValue: r'Data\ruRU\patch-ruRU.MPQ',
),
error: state == MwSyncPresentation.failure
? context.knobs.string(
label: 'error',
initialValue: 'Проверка SHA-256 не пройдена.',
)
: null,
onPrimary: () => debugPrint('Primary sync action requested: $state'),
onPause: state == MwSyncPresentation.downloading
? () => debugPrint('Sync pause requested')
: null,
),
);
}
@widgetbook.UseCase(
name: 'metadata',
type: MwLauncherStatusBar,
path: '[Launcher]',
)
Widget buildMwLauncherStatusBarMetadataUseCase(BuildContext context) {
return MwLauncherStatusBar(
status: context.knobs.string(
label: 'status',
initialValue: 'Загрузка игровых файлов',
),
fileCount: 'Файлы 124/480',
speed: '12.4 MB/s',
eta: 'ETA 00:01:42',
buildHash: 'a93fd1c2',
);
}
@widgetbook.UseCase(
name: 'default',
type: MwAccountAffordance,
path: '[Launcher]',
)
Widget buildMwAccountAffordanceUseCase(BuildContext context) {
return MwAccountAffordance(onLogout: () => debugPrint('Logout requested'));
}
@widgetbook.UseCase(
name: 'interactive',
type: MwSettingsPanel,
path: '[Launcher]',
)
Widget buildMwSettingsPanelInteractiveUseCase(BuildContext context) {
return SizedBox(
width: 420,
child: MwSettingsPanel(
installationPath: context.knobs.stringOrNull(
label: 'installationPath',
initialValue: r'C:\Games\MoonWell',
),
syncActive: context.knobs.boolean(
label: 'syncActive',
initialValue: false,
),
onChooseDirectory: () => debugPrint('Directory selection requested'),
onVerify: () => debugPrint('Manual verification requested'),
),
);
}
@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_primitives.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(name: 'interactive', type: MwButton, path: '[Primitives]')
Widget buildMwButtonInteractiveUseCase(BuildContext context) {
final variant = context.knobs.object.dropdown<MwButtonVariant>(
label: 'variant',
initialOption: MwButtonVariant.primary,
options: MwButtonVariant.values,
labelBuilder: (value) => value.name,
);
return MwButton(
label: context.knobs.string(label: 'label', initialValue: 'Играть'),
icon: context.knobs.boolean(label: 'showIcon', initialValue: true)
? Icons.play_arrow
: null,
variant: variant,
loading: context.knobs.boolean(label: 'loading', initialValue: false),
expand: context.knobs.boolean(label: 'expand', initialValue: false),
onPressed: context.knobs.boolean(label: 'enabled', initialValue: true)
? () => debugPrint('MwButton pressed')
: null,
);
}
@widgetbook.UseCase(name: 'loading', type: MwButton, path: '[Primitives]')
Widget buildMwButtonLoadingUseCase(BuildContext context) {
return MwButton(
label: context.knobs.string(label: 'label', initialValue: 'Загрузка'),
loading: true,
onPressed: () => debugPrint('Loading MwButton pressed'),
);
}
@widgetbook.UseCase(name: 'disabled', type: MwButton, path: '[Primitives]')
Widget buildMwButtonDisabledUseCase(BuildContext context) {
return MwButton(
label: context.knobs.string(label: 'label', initialValue: 'Недоступно'),
onPressed: null,
);
}
@widgetbook.UseCase(
name: 'interactive',
type: MwIconButton,
path: '[Primitives]',
)
Widget buildMwIconButtonInteractiveUseCase(BuildContext context) {
return MwIconButton(
icon: Icons.settings,
tooltip: context.knobs.string(label: 'tooltip', initialValue: 'Настройки'),
selected: context.knobs.boolean(label: 'selected', initialValue: false),
destructive: context.knobs.boolean(
label: 'destructive',
initialValue: false,
),
onPressed: context.knobs.boolean(label: 'enabled', initialValue: true)
? () => debugPrint('MwIconButton pressed')
: null,
);
}
@widgetbook.UseCase(
name: 'interactive',
type: MwTextField,
path: '[Primitives]',
)
Widget buildMwTextFieldInteractiveUseCase(BuildContext context) {
return SizedBox(
width: 380,
child: MwTextField(
label: context.knobs.string(label: 'label', initialValue: 'Логин'),
hint: context.knobs.stringOrNull(
label: 'hint',
initialValue: 'Введите логин',
),
errorText: context.knobs.stringOrNull(
label: 'errorText',
initialValue: null,
),
enabled: context.knobs.boolean(label: 'enabled', initialValue: true),
obscureText: context.knobs.boolean(
label: 'obscureText',
initialValue: false,
),
prefixIcon: context.knobs.boolean(label: 'showIcon', initialValue: true)
? Icons.person_outline
: null,
onSubmitted: (value) => debugPrint('MwTextField submitted: $value'),
),
);
}
@widgetbook.UseCase(name: 'interactive', type: MwCheckbox, path: '[Primitives]')
Widget buildMwCheckboxInteractiveUseCase(BuildContext context) {
return MwCheckbox(
label: context.knobs.string(label: 'label', initialValue: 'Запомнить меня'),
value: context.knobs.boolean(label: 'value', initialValue: false),
onChanged: context.knobs.boolean(label: 'enabled', initialValue: true)
? (value) => debugPrint('MwCheckbox changed: $value')
: null,
);
}
@widgetbook.UseCase(name: 'interactive', type: MwTabs, path: '[Primitives]')
Widget buildMwTabsInteractiveUseCase(BuildContext context) {
return MwTabs(
labels: const ['Новости', 'Обновления', 'Очень длинный раздел'],
selectedIndex: context.knobs.int.slider(
label: 'selectedIndex',
initialValue: 0,
min: 0,
max: 2,
divisions: 2,
),
disabledIndices:
context.knobs.boolean(label: 'disableFutureTabs', initialValue: true)
? const {1, 2}
: const {},
onSelected: (index) => debugPrint('MwTabs selected: $index'),
);
}
@widgetbook.UseCase(name: 'interactive', type: MwBadge, path: '[Primitives]')
Widget buildMwBadgeInteractiveUseCase(BuildContext context) {
return MwBadge(
label: context.knobs.string(label: 'label', initialValue: 'Готово'),
tone: context.knobs.object.dropdown<MwBadgeTone>(
label: 'tone',
initialOption: MwBadgeTone.success,
options: MwBadgeTone.values,
labelBuilder: (value) => value.name,
),
);
}
@widgetbook.UseCase(name: 'interactive', type: MwPanel, path: '[Primitives]')
Widget buildMwPanelInteractiveUseCase(BuildContext context) {
return SizedBox(
width: 420,
child: MwPanel(
level: context.knobs.object.dropdown<MwPanelLevel>(
label: 'level',
initialOption: MwPanelLevel.regular,
options: MwPanelLevel.values,
labelBuilder: (value) => value.name,
),
child: Text(
context.knobs.string(
label: 'content',
initialValue: 'Содержимое панели MoonWell',
),
),
),
);
}
@widgetbook.UseCase(
name: 'interactive',
type: MwProgressBar,
path: '[Primitives]',
)
Widget buildMwProgressBarInteractiveUseCase(BuildContext context) {
return SizedBox(
width: 440,
child: MwProgressBar(
value: context.knobs.boolean(label: 'indeterminate', initialValue: false)
? null
: context.knobs.double.slider(
label: 'value',
initialValue: .46,
min: 0,
max: 1,
divisions: 100,
),
label: context.knobs.stringOrNull(
label: 'label',
initialValue: 'Загрузка файлов',
),
state: context.knobs.object.dropdown<MwProgressState>(
label: 'state',
initialOption: MwProgressState.active,
options: MwProgressState.values,
labelBuilder: (value) => value.name,
),
),
);
}
@widgetbook.UseCase(
name: 'interactive',
type: MwStatusText,
path: '[Primitives]',
)
Widget buildMwStatusTextInteractiveUseCase(BuildContext context) {
return MwStatusText(
text: context.knobs.string(
label: 'text',
initialValue: 'Проверяем локальные файлы…',
),
tone: context.knobs.object.dropdown<MwStatusTone>(
label: 'tone',
initialOption: MwStatusTone.operational,
options: MwStatusTone.values,
labelBuilder: (value) => value.name,
),
monospace: context.knobs.boolean(label: 'monospace', initialValue: false),
);
}
@widgetbook.UseCase(
name: 'interactive',
type: MwStateView,
path: '[Primitives]',
)
Widget buildMwStateViewInteractiveUseCase(BuildContext context) {
final kind = context.knobs.object.dropdown<MwStateKind>(
label: 'kind',
initialOption: MwStateKind.empty,
options: MwStateKind.values,
labelBuilder: (value) => value.name,
);
return MwStateView(
kind: kind,
title: context.knobs.string(
label: 'title',
initialValue: 'Новостей пока нет',
),
description: context.knobs.stringOrNull(
label: 'description',
initialValue: 'Новые публикации появятся здесь.',
),
onRetry: kind == MwStateKind.error
? () => debugPrint('MwStateView retry pressed')
: null,
);
}
@widgetbook.UseCase(
name: 'default',
type: MwDisabledFeature,
path: '[Primitives]',
)
Widget buildMwDisabledFeatureUseCase(BuildContext context) {
return MwDisabledFeature(
message: context.knobs.string(
label: 'message',
initialValue: 'Регистрация появится позже',
),
child: const MwButton(
label: 'Регистрация · скоро',
onPressed: null,
variant: MwButtonVariant.secondary,
),
);
}
+248
View File
@@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_authentication.dart';
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
VoidCallback _windowCallback(String action) {
return () => debugPrint('Window action requested: $action');
}
@widgetbook.UseCase(name: 'default', type: MwBrandMark, path: '[Foundations]')
Widget buildMwBrandMarkUseCase(BuildContext context) {
return MwBrandMark(
width: context.knobs.double.slider(
label: 'width',
initialValue: 220,
min: 96,
max: 360,
divisions: 33,
),
);
}
@widgetbook.UseCase(
name: 'default',
type: MwArtworkBackground,
path: '[Foundations]',
)
Widget buildMwArtworkBackgroundUseCase(BuildContext context) {
return const MwArtworkBackground(
assetPackage: 'moonwell_launcher',
child: Center(child: Text('Safe content area')),
);
}
@widgetbook.UseCase(name: 'default', type: MwLoginShell, path: '[Compositions]')
Widget buildMwLoginShellUseCase(BuildContext context) {
return MwLoginShell(
assetPackage: 'moonwell_launcher',
form: MwAuthenticationForm(
usernameController: TextEditingController(),
passwordController: TextEditingController(),
onSubmit: () => debugPrint('Login submitted'),
),
onDrag: _windowCallback('drag'),
onDoubleTap: _windowCallback('double_tap'),
onMinimize: _windowCallback('minimize'),
onMaximizeRestore: _windowCallback('maximize_restore'),
onClose: _windowCallback('close'),
);
}
@widgetbook.UseCase(
name: 'api_error',
type: MwLoginShell,
path: '[Compositions]',
)
Widget buildMwLoginShellApiErrorUseCase(BuildContext context) {
return MwLoginShell(
assetPackage: 'moonwell_launcher',
form: MwAuthenticationForm(
usernameController: TextEditingController(text: 'moonwalker'),
passwordController: TextEditingController(),
error: context.knobs.string(
label: 'error',
initialValue: 'Неверный логин или пароль.',
),
onSubmit: () => debugPrint('Login submitted after API error'),
),
onDrag: _windowCallback('drag'),
onDoubleTap: _windowCallback('double_tap'),
onMinimize: _windowCallback('minimize'),
onMaximizeRestore: _windowCallback('maximize_restore'),
onClose: _windowCallback('close'),
);
}
@widgetbook.UseCase(
name: 'restored_session_loading',
type: MwLoginShell,
path: '[Compositions]',
)
Widget buildMwLoginShellRestoredSessionLoadingUseCase(BuildContext context) {
return MwLoginShell(
assetPackage: 'moonwell_launcher',
form: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Восстанавливаем защищённую сессию…'),
],
),
),
onDrag: _windowCallback('drag'),
onDoubleTap: _windowCallback('double_tap'),
onMinimize: _windowCallback('minimize'),
onMaximizeRestore: _windowCallback('maximize_restore'),
onClose: _windowCallback('close'),
);
}
MwLauncherShell _launcherShell({
required MwNewsListState newsState,
required MwSyncPresentation syncState,
required String status,
double? progress,
String? error,
}) {
return MwLauncherShell(
assetPackage: 'moonwell_launcher',
news: MwNewsList(
state: newsState,
error: newsState == MwNewsListState.failure
? 'Сервер новостей временно недоступен.'
: null,
items: newsState == MwNewsListState.populated
? List.generate(
8,
(index) => MwNewsItemData(
title: 'Новость ${index + 1}',
body: 'События игрового мира и важные обновления MoonWell.',
createdAt: DateTime(2026, 6, 22 - index),
),
)
: const [],
onRetry: () => debugPrint('News reload requested'),
),
syncActions: MwSyncActionArea(
state: syncState,
status: status,
progress: progress,
currentPath: syncState == MwSyncPresentation.downloading
? r'Data\ruRU\patch-ruRU.MPQ'
: null,
error: error,
onPrimary: () => debugPrint('Primary sync action requested'),
onPause: syncState == MwSyncPresentation.downloading
? () => debugPrint('Sync pause requested')
: null,
),
statusBar: const MwLauncherStatusBar(
status: 'MoonWell Launcher · локальная установка',
fileCount: '124/480',
buildHash: 'a93fd1c2',
),
account: MwAccountAffordance(
username: 'Aranthel',
onLogout: () => debugPrint('Logout requested'),
),
onSettings: () => debugPrint('Settings requested'),
username: 'Aranthel',
onDrag: _windowCallback('drag'),
onDoubleTap: _windowCallback('double_tap'),
onMinimize: _windowCallback('minimize'),
onMaximizeRestore: _windowCallback('maximize_restore'),
onClose: _windowCallback('close'),
);
}
@widgetbook.UseCase(
name: 'ready',
type: MwLauncherShell,
path: '[Compositions]',
)
Widget buildMwLauncherShellReadyUseCase(BuildContext context) {
return _launcherShell(
newsState: MwNewsListState.populated,
syncState: MwSyncPresentation.ready,
status: 'Клиент готов к запуску.',
);
}
@widgetbook.UseCase(
name: 'downloading',
type: MwLauncherShell,
path: '[Compositions]',
)
Widget buildMwLauncherShellDownloadingUseCase(BuildContext context) {
return _launcherShell(
newsState: MwNewsListState.populated,
syncState: MwSyncPresentation.downloading,
status: 'Загружаем обновление клиента…',
progress: context.knobs.double.slider(
label: 'progress',
initialValue: .46,
min: 0,
max: 1,
divisions: 100,
),
);
}
@widgetbook.UseCase(
name: 'paused',
type: MwLauncherShell,
path: '[Compositions]',
)
Widget buildMwLauncherShellPausedUseCase(BuildContext context) {
return _launcherShell(
newsState: MwNewsListState.populated,
syncState: MwSyncPresentation.paused,
status: 'Обновление приостановлено.',
progress: .46,
);
}
@widgetbook.UseCase(
name: 'verifying',
type: MwLauncherShell,
path: '[Compositions]',
)
Widget buildMwLauncherShellVerifyingUseCase(BuildContext context) {
return _launcherShell(
newsState: MwNewsListState.populated,
syncState: MwSyncPresentation.verifying,
status: 'Проверяем целостность установки…',
);
}
@widgetbook.UseCase(
name: 'failure',
type: MwLauncherShell,
path: '[Compositions]',
)
Widget buildMwLauncherShellFailureUseCase(BuildContext context) {
return _launcherShell(
newsState: MwNewsListState.failure,
syncState: MwSyncPresentation.failure,
status: 'Не удалось завершить обновление.',
error: 'Проверка SHA-256 не пройдена.',
);
}
@widgetbook.UseCase(
name: 'empty_news',
type: MwLauncherShell,
path: '[Compositions]',
)
Widget buildMwLauncherShellEmptyNewsUseCase(BuildContext context) {
return _launcherShell(
newsState: MwNewsListState.empty,
syncState: MwSyncPresentation.ready,
status: 'Клиент готов к запуску.',
);
}
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_window_chrome.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(
name: 'interactive',
type: MwWindowChrome,
path: '[Window Chrome]',
)
Widget buildMwWindowChromeInteractiveUseCase(BuildContext context) {
return MwWindowChrome(
onDrag: () => debugPrint('Window drag requested'),
onDoubleTap: () => debugPrint('Window maximize/restore requested'),
onMinimize: () => debugPrint('Window minimize requested'),
onMaximizeRestore: () => debugPrint('Window maximize/restore requested'),
onClose: () => debugPrint('Window close requested'),
showMaximize: context.knobs.boolean(label: 'Show maximize button'),
);
}
+7
View File
@@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
@@ -0,0 +1,24 @@
//
// Generated file. Do not edit.
//
import FlutterMacOS
import Foundation
import auto_updater_macos
import file_picker
import flutter_secure_storage_darwin
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"))
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
@@ -0,0 +1,825 @@
// !$*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 */
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 */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
A82F444CC8B3A4A9D77658B5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FF1E9F75AAD4A9DAA6A7192 /* Pods_Runner.framework */; };
D75F855AEFA955416C9E3CB5 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF66BD0165F006AA79AB2869 /* Pods_RunnerTests.framework */; };
/* 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 */
1FF1E9F75AAD4A9DAA6A7192 /* 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 /* widgetbook.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = widgetbook.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>"; };
35EAC1A04D0F64CA5C189356 /* 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>"; };
515CCD8F3833D9CA297F10EF /* 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>"; };
5478AE1693BA39F1CE84F4BE /* 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>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
7B8881A1E0279BE3DA0DA28B /* 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>"; };
804A1BB5B85FA4B1AD3A3AFD /* 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>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
AF66BD0165F006AA79AB2869 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C27E9A4553B65B33F4434D34 /* 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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
331C80D2294CF70F00263BE5 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D75F855AEFA955416C9E3CB5 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EA2044A3C60003C045 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
A82F444CC8B3A4A9D77658B5 /* 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 */,
542EB08614890ADE7265109E /* Pods */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* widgetbook.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>";
};
542EB08614890ADE7265109E /* Pods */ = {
isa = PBXGroup;
children = (
515CCD8F3833D9CA297F10EF /* Pods-Runner.debug.xcconfig */,
7B8881A1E0279BE3DA0DA28B /* Pods-Runner.release.xcconfig */,
5478AE1693BA39F1CE84F4BE /* Pods-Runner.profile.xcconfig */,
35EAC1A04D0F64CA5C189356 /* Pods-RunnerTests.debug.xcconfig */,
804A1BB5B85FA4B1AD3A3AFD /* Pods-RunnerTests.release.xcconfig */,
C27E9A4553B65B33F4434D34 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
1FF1E9F75AAD4A9DAA6A7192 /* Pods_Runner.framework */,
AF66BD0165F006AA79AB2869 /* 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 = (
A081369AD173B7B0E5A431B9 /* [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 */ = {
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
E24F7DF6829BA92C9292551B /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
A7E3C45DA7734DF67E9E2B88 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* widgetbook.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
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;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
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";
};
A081369AD173B7B0E5A431B9 /* [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;
};
A7E3C45DA7734DF67E9E2B88 /* [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;
};
E24F7DF6829BA92C9292551B /* [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;
};
/* 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 = 35EAC1A04D0F64CA5C189356 /* 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.widgetbook.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/widgetbook.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/widgetbook";
};
name = Debug;
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 804A1BB5B85FA4B1AD3A3AFD /* 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.widgetbook.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/widgetbook.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/widgetbook";
};
name = Release;
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C27E9A4553B65B33F4434D34 /* 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.widgetbook.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/widgetbook.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/widgetbook";
};
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_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 = 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_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
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_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 "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>

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