├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── build-android-release.yml │ ├── build-ios.yml │ ├── build.yml │ └── windows.yml ├── .gitignore ├── .metadata ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ ├── proguard-rules.pro │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable │ │ │ ├── launch_background.xml │ │ │ └── splash.png │ │ │ ├── mipmap │ │ │ └── icon.png │ │ │ ├── values-b+zh+CN │ │ │ └── strings.xml │ │ │ ├── values-b+zh+TW │ │ │ └── strings.xml │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ ├── values-v31 │ │ │ └── styles.xml │ │ │ ├── values │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ │ └── xml │ │ │ └── network_security_config.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── course │ ├── art.png │ ├── biological.png │ ├── building.png │ ├── business.png │ ├── chemical.png │ ├── circuit.png │ ├── computer.png │ ├── control.png │ ├── curriculum.png │ ├── design.png │ ├── economic.png │ ├── electricity.png │ ├── engineering.png │ ├── experiment.png │ ├── generality.png │ ├── geography.png │ ├── history.png │ ├── ideological.png │ ├── internship.png │ ├── language.png │ ├── literature.png │ ├── management.png │ ├── mathematics.png │ ├── mechanical.png │ ├── music.png │ ├── physical.png │ ├── political .png │ ├── practice.png │ ├── principle.png │ ├── reading.png │ ├── running.png │ ├── social.png │ ├── sports.png │ ├── statistical.png │ ├── technology.png │ └── training.png ├── fonts │ └── ywb_iconfont.ttf ├── icon.png ├── icon.svg └── l10n │ └── zh-Hans.yaml ├── devtools_options.yaml ├── lib ├── agreements │ ├── entity │ │ └── agreements.dart │ ├── i18n.dart │ ├── page │ │ └── privacy_policy.dart │ ├── settings.dart │ └── widget │ │ └── agreements.dart ├── app.dart ├── backend │ └── entity │ │ └── user.dart ├── credentials │ ├── entity │ │ ├── credential.dart │ │ ├── credential.g.dart │ │ ├── login_status.dart │ │ ├── login_status.g.dart │ │ ├── user_type.dart │ │ └── user_type.g.dart │ ├── error.dart │ ├── i18n.dart │ ├── init.dart │ ├── storage │ │ └── credential.dart │ └── utils.dart ├── design │ ├── adaptive │ │ ├── dialog.dart │ │ ├── editor.dart │ │ ├── foundation.dart │ │ ├── menu.dart │ │ ├── multiplatform.dart │ │ └── swipe.dart │ ├── animation │ │ ├── animated.dart │ │ ├── button.dart │ │ ├── marquee.dart │ │ ├── number.dart │ │ └── progress.dart │ ├── dash.dart │ ├── entity │ │ ├── dual_color.dart │ │ └── dual_color.g.dart │ └── widget │ │ ├── app.dart │ │ ├── card.dart │ │ ├── common.dart │ │ ├── duration_picker.dart │ │ ├── entry_card.dart │ │ ├── expansion_tile.dart │ │ ├── grouped.dart │ │ ├── list_tile.dart │ │ ├── multi_select.dart │ │ ├── navigation.dart │ │ ├── tags.dart │ │ ├── task_builder.dart │ │ └── tooltip.dart ├── entity │ ├── campus.dart │ ├── campus.g.dart │ ├── meta.dart │ └── uuid.dart ├── files.dart ├── index.dart ├── init.dart ├── l10n │ ├── app.dart │ ├── common.dart │ ├── extension.dart │ ├── lang.dart │ ├── time.dart │ ├── tr.dart │ └── yaml_assets_loader.dart ├── lifecycle.dart ├── login │ ├── i18n.dart │ ├── init.dart │ ├── page │ │ ├── captcha.dart │ │ ├── index.dart │ │ └── sheet.dart │ ├── service │ │ └── authserver.dart │ ├── utils.dart │ ├── widget │ │ └── forgot_pwd.dart │ └── x.dart ├── main.dart ├── network │ └── dio.dart ├── r.dart ├── route.dart ├── school │ ├── electricity │ │ ├── card.dart │ │ ├── entity │ │ │ ├── balance.dart │ │ │ ├── balance.g.dart │ │ │ └── room.dart │ │ ├── i18n.dart │ │ ├── init.dart │ │ ├── service │ │ │ └── electricity.dart │ │ ├── storage │ │ │ └── electricity.dart │ │ ├── widget │ │ │ ├── card.dart │ │ │ └── search.dart │ │ └── x.dart │ ├── entity │ │ ├── icon.dart │ │ ├── school.dart │ │ ├── school.g.dart │ │ └── timetable.dart │ ├── event.dart │ ├── exam_arrange │ │ ├── card.dart │ │ ├── entity │ │ │ ├── exam.dart │ │ │ └── exam.g.dart │ │ ├── i18n.dart │ │ ├── init.dart │ │ ├── page │ │ │ └── list.dart │ │ ├── service │ │ │ └── exam.dart │ │ ├── storage │ │ │ └── exam.dart │ │ └── widget │ │ │ └── exam.dart │ ├── exam_result │ │ ├── card.pg.dart │ │ ├── card.ug.dart │ │ ├── entity │ │ │ ├── gpa.dart │ │ │ ├── result.pg.dart │ │ │ ├── result.pg.g.dart │ │ │ ├── result.ug.dart │ │ │ └── result.ug.g.dart │ │ ├── i18n.dart │ │ ├── init.dart │ │ ├── page │ │ │ ├── details.gpa.dart │ │ │ ├── details.ug.dart │ │ │ ├── gpa.dart │ │ │ ├── result.pg.dart │ │ │ └── result.ug.dart │ │ ├── service │ │ │ ├── result.pg.dart │ │ │ └── result.ug.dart │ │ ├── storage │ │ │ ├── result.pg.dart │ │ │ └── result.ug.dart │ │ ├── utils.dart │ │ ├── widget │ │ │ ├── pg.dart │ │ │ └── ug.dart │ │ └── x.dart │ ├── expense_records │ │ ├── card.dart │ │ ├── entity │ │ │ ├── local.dart │ │ │ ├── local.g.dart │ │ │ ├── remote.dart │ │ │ ├── remote.g.dart │ │ │ └── statistics.dart │ │ ├── i18n.dart │ │ ├── init.dart │ │ ├── page │ │ │ ├── records.dart │ │ │ └── statistics.dart │ │ ├── service │ │ │ └── fetch.dart │ │ ├── storage │ │ │ └── local.dart │ │ ├── utils.dart │ │ ├── widget │ │ │ ├── balance.dart │ │ │ ├── chart │ │ │ │ ├── bar.dart │ │ │ │ ├── delegate.dart │ │ │ │ ├── header.dart │ │ │ │ └── pie.dart │ │ │ ├── group.dart │ │ │ ├── selector.dart │ │ │ └── transaction.dart │ │ └── x.dart │ ├── i18n.dart │ ├── index.dart │ ├── init.dart │ ├── oa_announce │ │ ├── entity │ │ │ ├── announce.dart │ │ │ ├── announce.g.dart │ │ │ └── page.dart │ │ ├── i18n.dart │ │ ├── index.dart │ │ ├── init.dart │ │ ├── page │ │ │ ├── details.dart │ │ │ └── list.dart │ │ ├── service │ │ │ └── announce.dart │ │ ├── storage │ │ │ └── announce.dart │ │ └── widget │ │ │ ├── attachment.dart │ │ │ └── tile.dart │ ├── page │ │ └── settings.dart │ ├── settings.dart │ ├── utils.dart │ ├── widget │ │ ├── campus.dart │ │ ├── course.dart │ │ └── semester.dart │ └── ywb │ │ ├── entity │ │ ├── application.dart │ │ ├── application.g.dart │ │ ├── service.dart │ │ └── service.g.dart │ │ ├── i18n.dart │ │ ├── index.dart │ │ ├── init.dart │ │ ├── page │ │ ├── application.dart │ │ ├── details.dart │ │ └── service.dart │ │ ├── service │ │ ├── application.dart │ │ └── service.dart │ │ ├── storage │ │ ├── application.dart │ │ └── service.dart │ │ └── widget │ │ ├── application.dart │ │ ├── detail.dart │ │ └── service.dart ├── session │ ├── freshman.dart │ ├── pg_registration.dart │ ├── sso.dart │ ├── ug_registration.dart │ └── ywb.dart ├── settings │ ├── i18n.dart │ ├── meta.dart │ ├── page │ │ ├── about.dart │ │ ├── index.dart │ │ ├── oa.dart │ │ └── theme_color.dart │ ├── settings.dart │ └── widget │ │ ├── credentials.dart │ │ └── device.dart ├── storage │ ├── hive │ │ ├── adapter.dart │ │ ├── builtin.dart │ │ ├── cookie.dart │ │ ├── init.dart │ │ ├── table.dart │ │ └── type_id.dart │ └── prefs.dart ├── timetable │ ├── entity │ │ ├── course.dart │ │ ├── course.g.dart │ │ ├── display.dart │ │ ├── issue.dart │ │ ├── loc.dart │ │ ├── loc.g.dart │ │ ├── pos.dart │ │ ├── pos.g.dart │ │ ├── timetable.dart │ │ ├── timetable.g.dart │ │ ├── timetable_entity.dart │ │ └── timetable_entity.g.dart │ ├── events.dart │ ├── i18n.dart │ ├── init.dart │ ├── p13n │ │ ├── builtin.dart │ │ ├── entity │ │ │ ├── cell_style.dart │ │ │ ├── cell_style.g.dart │ │ │ ├── palette.dart │ │ │ └── palette.g.dart │ │ ├── page │ │ │ └── cell_style.dart │ │ └── widget │ │ │ ├── style.dart │ │ │ └── style.g.dart │ ├── page │ │ ├── edit │ │ │ ├── course_editor.dart │ │ │ └── editor.dart │ │ ├── import.dart │ │ ├── index.dart │ │ ├── mine.dart │ │ ├── preview.dart │ │ ├── settings.dart │ │ └── timetable.dart │ ├── service │ │ └── school.dart │ ├── settings.dart │ ├── storage │ │ └── timetable.dart │ ├── utils.dart │ ├── utils │ │ ├── export.dart │ │ ├── freshman.dart │ │ ├── import.dart │ │ ├── parse.pg.dart │ │ └── parse.ug.dart │ └── widget │ │ ├── course.dart │ │ ├── focus.dart │ │ ├── free.dart │ │ ├── issue.dart │ │ └── timetable │ │ ├── board.dart │ │ ├── course_sheet.dart │ │ ├── daily.dart │ │ ├── header.dart │ │ └── weekly.dart ├── utils │ ├── async_event.dart │ ├── collection.dart │ ├── color.dart │ ├── date.dart │ ├── dio.dart │ ├── error.dart │ ├── format.dart │ ├── guard_launch.dart │ ├── hive.dart │ ├── iconfont.dart │ ├── json.dart │ ├── permission.dart │ ├── riverpod.dart │ ├── save.dart │ ├── scroll_detector.dart │ ├── state_notifier.dart │ ├── strings.dart │ └── tel.dart └── widget │ ├── captcha_box.dart │ ├── html.dart │ ├── image.dart │ ├── inapp_webview │ └── page.dart │ ├── markdown.dart │ ├── modal_image_view.dart │ ├── not_found.dart │ └── search.dart ├── pubspec.lock └── pubspec.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.svg] 12 | insert_final_newline = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | text=auto 2 | 3 | *.json eol=lf 4 | -------------------------------------------------------------------------------- /.github/workflows/build-android-release.yml: -------------------------------------------------------------------------------- 1 | name: Flutter Build Android Release 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | build_android: 7 | runs-on: ubuntu-latest 8 | if: github.ref == 'refs/heads/master' 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | repository: plum-tech/xiaoying-x 14 | token: ${{ secrets.MIMIR_GITHUB_TOKEN }} 15 | fetch-depth: 0 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v4 19 | with: 20 | version: 9 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 22 25 | cache: 'pnpm' 26 | cache-dependency-path: tools/pnpm-lock.yaml 27 | 28 | - name: Pnpm install 29 | run: | 30 | cd tools && pnpm i && pnpm build && cd .. 31 | 32 | - name: Install JDK 17 33 | uses: actions/setup-java@v4 34 | with: 35 | distribution: 'temurin' 36 | java-version: '17' 37 | 38 | - name: Install Flutter 39 | uses: subosito/flutter-action@v2 40 | with: 41 | channel: stable 42 | cache: true 43 | flutter-version-file: pubspec.yaml 44 | 45 | - name: Build APK 46 | run: | 47 | flutter config --no-cli-animations 48 | flutter build apk --release --target-platform android-arm,android-arm64 --split-per-abi 49 | 50 | - name: Sign APK 51 | uses: r0adkll/sign-android-release@v1 52 | id: sign_apk 53 | with: 54 | releaseDirectory: build/app/outputs/flutter-apk 55 | signingKeyBase64: ${{ secrets.APK_SIGN_JKS_BASE64 }} 56 | keyStorePassword: ${{ secrets.APK_SIGN_JKS_PASSWORD }} 57 | keyPassword: ${{ secrets.APK_SIGN_ALIAS_PASS }} 58 | alias: ${{ secrets.APK_SIGN_ALIAS }} 59 | env: 60 | BUILD_TOOLS_VERSION: "34.0.0" 61 | 62 | - name: Publish Android Artifact 63 | uses: actions/upload-artifact@v4 64 | with: 65 | name: Android-release 66 | path: build/app/outputs/flutter-apk/*-signed.apk 67 | -------------------------------------------------------------------------------- /.github/workflows/windows.yml: -------------------------------------------------------------------------------- 1 | name: Windows 2 | on: workflow_dispatch 3 | 4 | env: 5 | flutter_version: '3.24.4' 6 | 7 | jobs: 8 | build: 9 | runs-on: windows-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | repository: plum-tech/xiaoying-x 14 | token: ${{ secrets.MIMIR_GITHUB_TOKEN }} 15 | fetch-depth: 0 16 | 17 | - name: Install Flutter 18 | uses: subosito/flutter-action@v2 19 | with: 20 | flutter-version: ${{ env.flutter_version }} 21 | channel: stable 22 | cache: true 23 | 24 | - name: Setup Flutter 25 | run: | 26 | flutter config --no-cli-animations 27 | 28 | - name: Build Windows 29 | run: | 30 | flutter pub run build_runner build --delete-conflicting-outputs 31 | dart run msix:create 32 | 33 | - name: Upload building 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: SITLife-Windows-release 37 | path: build\windows\x64\runner\Release\SIT-Life.msix 38 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "5dcb86f68f239346676ceb1ed1ea385bd215fba1" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1 17 | base_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1 18 | - platform: web 19 | create_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1 20 | base_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Xiao Ying 小应生活 4 | 5 | Icon 6 | 7 | ### 校园生活就用小应生活! 8 | 9 | ### Source License 10 | 11 | The source codes and configurations are licensed under [Plum Technology Ltd. Software License Agreement](LICENSE). 12 | 13 | ### Copyright 14 | 15 | Copyright©️2024-2025 [Plum Technology Ltd](https://www.liplum.net). All Rights Reserved. 16 | 17 |
18 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | analyzer: 13 | exclude: 14 | - lib/**/*.g.dart 15 | 16 | 17 | linter: 18 | # The lint rules applied to this project can be customized in the 19 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 20 | # included above or to enable additional rules. A list of all available lints 21 | # and their documentation is published at 22 | # https://dart-lang.github.io/linter/lints/index.html. 23 | # 24 | # Instead of disabling a lint rule for the entire project in the 25 | # section below, it can also be suppressed for a single line of code 26 | # or a specific dart file by using the `// ignore: name_of_lint` and 27 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 28 | # producing the lint. 29 | rules: 30 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 31 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 32 | 33 | # Additional information about this file can be found at 34 | # https://dart.dev/guides/language/analysis-options 35 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Flutter 2 | # fix by https://github.com/juliansteenbakker/mobile_scanner/issues/614#issuecomment-1665473831 3 | -keep public class androidx.camera.core.impl.CameraCaptureMetaData$** { *; } 4 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package net.liplum.mimir 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/android/app/src/main/res/drawable/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/android/app/src/main/res/mipmap/icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-b+zh+CN/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 小应生活 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-b+zh+TW/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 小鷹生活 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-v31/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | SIT Life 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 17 | 21 | 22 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | allprojects { 9 | // fix https://github.com/jonataslaw/VideoCompress/issues/255 10 | afterEvaluate { project -> 11 | if (project.hasProperty("kotlin")) { 12 | project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 13 | kotlinOptions { 14 | jvmTarget = "1.8" 15 | } 16 | } 17 | } 18 | } 19 | } 20 | allprojects { 21 | // fix for verifyReleaseResources: https://github.com/isar/isar/issues/1654#issuecomment-2295028039 22 | afterEvaluate { project -> 23 | if (project.plugins.hasPlugin("com.android.application") || 24 | project.plugins.hasPlugin("com.android.library")) { 25 | project.android { 26 | compileSdkVersion 35 27 | buildToolsVersion "35.0.0" 28 | } 29 | } 30 | } 31 | } 32 | 33 | allprojects { 34 | afterEvaluate { project -> 35 | // check only for "com.android.library" to not modify 36 | // your "app" subproject. All plugins will have "com.android.library" plugin, and only your app "com.android.application" 37 | // Change your application's namespace in main build.gradle and in main android block. 38 | 39 | if (project.hasProperty("android")) { 40 | project.android { 41 | if (namespace == null) { 42 | namespace project.group 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | rootProject.buildDir = '../build' 50 | subprojects { 51 | project.buildDir = "${rootProject.buildDir}/${project.name}" 52 | } 53 | subprojects { 54 | project.evaluationDependsOn(':app') 55 | } 56 | 57 | tasks.register("clean", Delete) { 58 | delete rootProject.buildDir 59 | } 60 | 61 | configurations.all { 62 | resolutionStrategy { 63 | force 'androidx.core:core:1.6.0' 64 | force 'androidx.core:core-ktx:1.6.0' 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4096M --add-exports=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-exports=jdk.unsupported/sun.misc=ALL-UNNAMED 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | android.jetifier.ignorelist=bcprov-jdk15on 5 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip 6 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | } 9 | settings.ext.flutterSdkPath = flutterSdkPath() 10 | 11 | includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") 12 | 13 | repositories { 14 | google() 15 | mavenCentral() 16 | gradlePluginPortal() 17 | } 18 | } 19 | 20 | plugins { 21 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 22 | id "com.android.application" version "8.7.0" apply false 23 | id "org.jetbrains.kotlin.android" version "1.8.10" apply false 24 | } 25 | 26 | include ":app" 27 | -------------------------------------------------------------------------------- /assets/course/art.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/art.png -------------------------------------------------------------------------------- /assets/course/biological.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/biological.png -------------------------------------------------------------------------------- /assets/course/building.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/building.png -------------------------------------------------------------------------------- /assets/course/business.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/business.png -------------------------------------------------------------------------------- /assets/course/chemical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/chemical.png -------------------------------------------------------------------------------- /assets/course/circuit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/circuit.png -------------------------------------------------------------------------------- /assets/course/computer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/computer.png -------------------------------------------------------------------------------- /assets/course/control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/control.png -------------------------------------------------------------------------------- /assets/course/curriculum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/curriculum.png -------------------------------------------------------------------------------- /assets/course/design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/design.png -------------------------------------------------------------------------------- /assets/course/economic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/economic.png -------------------------------------------------------------------------------- /assets/course/electricity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/electricity.png -------------------------------------------------------------------------------- /assets/course/engineering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/engineering.png -------------------------------------------------------------------------------- /assets/course/experiment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/experiment.png -------------------------------------------------------------------------------- /assets/course/generality.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/generality.png -------------------------------------------------------------------------------- /assets/course/geography.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/geography.png -------------------------------------------------------------------------------- /assets/course/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/history.png -------------------------------------------------------------------------------- /assets/course/ideological.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/ideological.png -------------------------------------------------------------------------------- /assets/course/internship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/internship.png -------------------------------------------------------------------------------- /assets/course/language.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/language.png -------------------------------------------------------------------------------- /assets/course/literature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/literature.png -------------------------------------------------------------------------------- /assets/course/management.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/management.png -------------------------------------------------------------------------------- /assets/course/mathematics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/mathematics.png -------------------------------------------------------------------------------- /assets/course/mechanical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/mechanical.png -------------------------------------------------------------------------------- /assets/course/music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/music.png -------------------------------------------------------------------------------- /assets/course/physical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/physical.png -------------------------------------------------------------------------------- /assets/course/political .png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/political .png -------------------------------------------------------------------------------- /assets/course/practice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/practice.png -------------------------------------------------------------------------------- /assets/course/principle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/principle.png -------------------------------------------------------------------------------- /assets/course/reading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/reading.png -------------------------------------------------------------------------------- /assets/course/running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/running.png -------------------------------------------------------------------------------- /assets/course/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/social.png -------------------------------------------------------------------------------- /assets/course/sports.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/sports.png -------------------------------------------------------------------------------- /assets/course/statistical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/statistical.png -------------------------------------------------------------------------------- /assets/course/technology.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/technology.png -------------------------------------------------------------------------------- /assets/course/training.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/course/training.png -------------------------------------------------------------------------------- /assets/fonts/ywb_iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/fonts/ywb_iconfont.ttf -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plum-tech/xiaoying/0bede0a9e177195ea8c6d1a37daad8be5f426826/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /devtools_options.yaml: -------------------------------------------------------------------------------- 1 | description: This file stores settings for Dart & Flutter DevTools. 2 | documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states 3 | extensions: 4 | -------------------------------------------------------------------------------- /lib/agreements/entity/agreements.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | 3 | enum AgreementType { 4 | basic, 5 | account; 6 | 7 | String l10n() => "agreements.basic".tr(); 8 | } 9 | 10 | enum AgreementVersion { 11 | v20240915("20240915"), 12 | v20241118("20241118"), 13 | ; 14 | 15 | static const current = v20241118; 16 | 17 | final String number; 18 | 19 | const AgreementVersion(this.number); 20 | } 21 | -------------------------------------------------------------------------------- /lib/agreements/i18n.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:mimir/l10n/common.dart'; 3 | 4 | const i18n = _I18n(); 5 | 6 | class _I18n with CommonI18nMixin { 7 | static const ns = "agreements"; 8 | 9 | const _I18n(); 10 | 11 | String get acceptanceRequired => "$ns.acceptanceRequired.title".tr(); 12 | 13 | String get acceptanceRequiredDesc => "$ns.acceptanceRequired.desc".tr(); 14 | 15 | String get privacyPolicy => "$ns.privacyPolicy.title".tr(); 16 | 17 | String get privacyPolicyContent => "$ns.privacyPolicy.content".tr(); 18 | 19 | String get accept => "$ns.privacyPolicy.accept".tr(); 20 | 21 | String get decline => "$ns.privacyPolicy.decline".tr(); 22 | 23 | String get onDecline => "$ns.privacyPolicy.onDecline".tr(); 24 | } 25 | -------------------------------------------------------------------------------- /lib/agreements/settings.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | import 'package:mimir/utils/hive.dart'; 3 | 4 | import 'entity/agreements.dart'; 5 | 6 | class _AgreementsK { 7 | static const ns = "/agreements"; 8 | 9 | static String acceptanceKeyOf(AgreementType type, AgreementVersion version) => 10 | "$ns/acceptance/${type.name}/${version.number}"; 11 | } 12 | 13 | class AgreementsSettings { 14 | final Box box; 15 | 16 | AgreementsSettings(this.box); 17 | 18 | bool? getBasicAcceptanceOf(AgreementVersion version) => 19 | box.safeGet(_AgreementsK.acceptanceKeyOf(AgreementType.basic, version)); 20 | 21 | Future setBasicAcceptanceOf(AgreementVersion version, bool? newV) async => 22 | await box.safePut(_AgreementsK.acceptanceKeyOf(AgreementType.basic, version), newV); 23 | 24 | late final $basicAcceptanceOf = box.providerFamily( 25 | (version) => _AgreementsK.acceptanceKeyOf(AgreementType.basic, version), 26 | get: getBasicAcceptanceOf, 27 | set: setBasicAcceptanceOf, 28 | ); 29 | 30 | bool? getAccountAcceptanceOf(AgreementVersion version) => 31 | box.safeGet(_AgreementsK.acceptanceKeyOf(AgreementType.account, version)); 32 | 33 | Future setAccountAcceptanceOf(AgreementVersion version, bool? newV) async => 34 | await box.safePut(_AgreementsK.acceptanceKeyOf(AgreementType.account, version), newV); 35 | 36 | late final $accountAcceptanceOf = box.providerFamily( 37 | (version) => _AgreementsK.acceptanceKeyOf(AgreementType.account, version), 38 | get: getAccountAcceptanceOf, 39 | set: setAccountAcceptanceOf, 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /lib/agreements/widget/agreements.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:mimir/design/adaptive/dialog.dart'; 4 | import 'package:mimir/settings/settings.dart'; 5 | import 'package:mimir/widget/markdown.dart'; 6 | import 'package:rettulf/rettulf.dart'; 7 | 8 | import '../entity/agreements.dart'; 9 | import '../i18n.dart'; 10 | 11 | class AgreementsCheckBox extends ConsumerWidget { 12 | final AgreementType type; 13 | 14 | const AgreementsCheckBox({ 15 | super.key, 16 | required this.type, 17 | }); 18 | 19 | const AgreementsCheckBox.basic({ 20 | super.key, 21 | }) : type = AgreementType.basic; 22 | 23 | const AgreementsCheckBox.account({ 24 | super.key, 25 | }) : type = AgreementType.account; 26 | 27 | @override 28 | Widget build(BuildContext context, WidgetRef ref) { 29 | final agreements = Settings.agreements; 30 | final acceptance = switch (type) { 31 | AgreementType.basic => agreements.$basicAcceptanceOf, 32 | AgreementType.account => agreements.$accountAcceptanceOf, 33 | }; 34 | final $accepted = acceptance(AgreementVersion.current); 35 | final accepted = ref.watch($accepted) ?? false; 36 | return [ 37 | Checkbox.adaptive( 38 | value: accepted, 39 | onChanged: (newV) { 40 | ref.read($accepted.notifier).set(newV); 41 | }, 42 | ), 43 | FeaturedMarkdownWidget(data: type.l10n()).expanded(flex: 9), 44 | ].row(); 45 | } 46 | } 47 | 48 | Future showAgreementsRequired2Accept(BuildContext context) async { 49 | await context.showTip( 50 | title: i18n.acceptanceRequired, 51 | desc: i18n.acceptanceRequiredDesc, 52 | primary: i18n.ok, 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /lib/backend/entity/user.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:json_annotation/json_annotation.dart'; 3 | 4 | @JsonEnum() 5 | enum SchoolCode { 6 | sit("10259"); 7 | 8 | final String code; 9 | 10 | const SchoolCode(this.code); 11 | 12 | String l10n() => "mimir.school.$code".tr(); 13 | 14 | static final code2enum = Map.fromEntries(values.map((v) => MapEntry(v.code, v))); 15 | } 16 | -------------------------------------------------------------------------------- /lib/credentials/entity/credential.dart: -------------------------------------------------------------------------------- 1 | import 'package:copy_with_extension/copy_with_extension.dart'; 2 | import 'package:mimir/storage/hive/type_id.dart'; 3 | 4 | part 'credential.g.dart'; 5 | 6 | @HiveType(typeId: CoreHiveType.credentials) 7 | @CopyWith(skipFields: true) 8 | class Credential { 9 | @HiveField(0) 10 | final String account; 11 | @HiveField(1) 12 | final String password; 13 | 14 | const Credential({ 15 | required this.account, 16 | required this.password, 17 | }); 18 | 19 | @override 20 | String toString() => 'account:"$account", password:"$password"'; 21 | 22 | @override 23 | bool operator ==(Object other) { 24 | return other is Credential && 25 | runtimeType == other.runtimeType && 26 | account == other.account && 27 | password == other.password; 28 | } 29 | 30 | @override 31 | int get hashCode => Object.hash(account, password); 32 | } 33 | -------------------------------------------------------------------------------- /lib/credentials/entity/login_status.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/storage/hive/type_id.dart'; 2 | 3 | part 'login_status.g.dart'; 4 | 5 | @HiveType(typeId: CoreHiveType.oaLoginStatus) 6 | enum OaLoginStatus { 7 | @HiveField(0) 8 | never, 9 | @HiveField(2) 10 | offline, 11 | @HiveField(3) 12 | validated, 13 | @HiveField(4) 14 | everLogin, 15 | } 16 | -------------------------------------------------------------------------------- /lib/credentials/entity/login_status.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'login_status.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class OaLoginStatusAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 5; 12 | 13 | @override 14 | OaLoginStatus read(BinaryReader reader) { 15 | switch (reader.readByte()) { 16 | case 0: 17 | return OaLoginStatus.never; 18 | case 2: 19 | return OaLoginStatus.offline; 20 | case 3: 21 | return OaLoginStatus.validated; 22 | case 4: 23 | return OaLoginStatus.everLogin; 24 | default: 25 | return OaLoginStatus.never; 26 | } 27 | } 28 | 29 | @override 30 | void write(BinaryWriter writer, OaLoginStatus obj) { 31 | switch (obj) { 32 | case OaLoginStatus.never: 33 | writer.writeByte(0); 34 | break; 35 | case OaLoginStatus.offline: 36 | writer.writeByte(2); 37 | break; 38 | case OaLoginStatus.validated: 39 | writer.writeByte(3); 40 | break; 41 | case OaLoginStatus.everLogin: 42 | writer.writeByte(4); 43 | break; 44 | } 45 | } 46 | 47 | @override 48 | int get hashCode => typeId.hashCode; 49 | 50 | @override 51 | bool operator ==(Object other) => 52 | identical(this, other) || 53 | other is OaLoginStatusAdapter && runtimeType == other.runtimeType && typeId == other.typeId; 54 | } 55 | -------------------------------------------------------------------------------- /lib/credentials/entity/user_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:mimir/storage/hive/type_id.dart'; 3 | 4 | part 'user_type.g.dart'; 5 | 6 | @HiveType(typeId: CoreHiveType.oaUserType) 7 | enum OaUserType { 8 | @HiveField(0) 9 | undergraduate(), 10 | @HiveField(1) 11 | postgraduate(), 12 | @HiveField(2) 13 | freshman(), 14 | @HiveField(3) 15 | worker(), 16 | @HiveField(4) 17 | none(); 18 | 19 | const OaUserType(); 20 | 21 | String l10n() => "OaUserType.$name".tr(); 22 | } 23 | -------------------------------------------------------------------------------- /lib/credentials/entity/user_type.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'user_type.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class OaUserTypeAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 6; 12 | 13 | @override 14 | OaUserType read(BinaryReader reader) { 15 | switch (reader.readByte()) { 16 | case 0: 17 | return OaUserType.undergraduate; 18 | case 1: 19 | return OaUserType.postgraduate; 20 | case 2: 21 | return OaUserType.freshman; 22 | case 3: 23 | return OaUserType.worker; 24 | case 4: 25 | return OaUserType.none; 26 | default: 27 | return OaUserType.undergraduate; 28 | } 29 | } 30 | 31 | @override 32 | void write(BinaryWriter writer, OaUserType obj) { 33 | switch (obj) { 34 | case OaUserType.undergraduate: 35 | writer.writeByte(0); 36 | break; 37 | case OaUserType.postgraduate: 38 | writer.writeByte(1); 39 | break; 40 | case OaUserType.freshman: 41 | writer.writeByte(2); 42 | break; 43 | case OaUserType.worker: 44 | writer.writeByte(3); 45 | break; 46 | case OaUserType.none: 47 | writer.writeByte(4); 48 | break; 49 | } 50 | } 51 | 52 | @override 53 | int get hashCode => typeId.hashCode; 54 | 55 | @override 56 | bool operator ==(Object other) => 57 | identical(this, other) || 58 | other is OaUserTypeAdapter && runtimeType == other.runtimeType && typeId == other.typeId; 59 | } 60 | -------------------------------------------------------------------------------- /lib/credentials/error.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | 3 | class CredentialErrorType { 4 | final String type; 5 | 6 | const CredentialErrorType._(this.type); 7 | 8 | static const // 9 | accountPassword = CredentialErrorType._("accountPassword"), 10 | captcha = CredentialErrorType._("captcha"), 11 | frozen = CredentialErrorType._("frozen"), 12 | locked = CredentialErrorType._("locked"); 13 | static const // 14 | oaFrozen = CredentialErrorType._("oa-frozen"), 15 | oaLocked = CredentialErrorType._("oa-locked"), 16 | oaIncompleteUserInfo = CredentialErrorType._("oa-incompleteUserInfo"); 17 | 18 | String l10n() => "credentials.error.$type".tr(); 19 | 20 | @override 21 | String toString() { 22 | return type; 23 | } 24 | } 25 | 26 | class CredentialException implements Exception { 27 | final CredentialErrorType type; 28 | final String? message; 29 | 30 | const CredentialException({ 31 | required this.type, 32 | this.message, 33 | }); 34 | 35 | @override 36 | String toString() { 37 | final message = this.message; 38 | if (message == null) return "CredentialsException"; 39 | return "CredentialsException: $type $message"; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/credentials/i18n.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | 3 | class CredentialsI18n with CredentialsI18nMixin { 4 | const CredentialsI18n(); 5 | } 6 | 7 | mixin class CredentialsI18nMixin { 8 | static const ns = "credentials"; 9 | 10 | String get account => "$ns.account".tr(); 11 | 12 | String get pwd => "$ns.pwd".tr(); 13 | 14 | String get savedPwd => "$ns.savedPwd.title".tr(); 15 | 16 | String get savedPwdDesc => "$ns.savedPwd.desc".tr(); 17 | 18 | String get credentialsError => "$ns.credentialsError".tr(); 19 | } 20 | 21 | class OaCredentialsI18n extends CredentialsI18n { 22 | const OaCredentialsI18n(); 23 | 24 | static const ns = "${CredentialsI18nMixin.ns}.oa"; 25 | 26 | String get studentId => "$ns.studentId".tr(); 27 | 28 | String get oaAccount => "$ns.oaAccount".tr(); 29 | 30 | String get oaPwd => "$ns.oaPwd".tr(); 31 | 32 | String get oaPwdChangedOutsideRequest => "$ns.oaPwdChangedOutsideRequest".tr(); 33 | 34 | String get goSettings => "$ns.goSettings".tr(); 35 | } 36 | 37 | class EmailCredentialsI18n extends CredentialsI18n { 38 | const EmailCredentialsI18n(); 39 | 40 | static const ns = "${CredentialsI18nMixin.ns}.email"; 41 | 42 | String get eduEmail => "$ns.eduEmail".tr(); 43 | 44 | String get emailAddress => "$ns.emailAddress".tr(); 45 | } 46 | -------------------------------------------------------------------------------- /lib/credentials/init.dart: -------------------------------------------------------------------------------- 1 | import 'storage/credential.dart'; 2 | 3 | class CredentialsInit { 4 | static late CredentialStorage storage; 5 | 6 | static void init() {} 7 | 8 | static void initStorage() { 9 | storage = CredentialStorage(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/credentials/storage/credential.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive_flutter/hive_flutter.dart'; 2 | import 'package:mimir/credentials/entity/user_type.dart'; 3 | import 'package:mimir/storage/hive/init.dart'; 4 | import 'package:mimir/utils/hive.dart'; 5 | 6 | import '../entity/credential.dart'; 7 | import '../entity/login_status.dart'; 8 | 9 | class CredentialStorage { 10 | Box get box => HiveInit.credentials; 11 | 12 | CredentialStorage(); 13 | 14 | late final oa = _Oa(box); 15 | } 16 | 17 | class _OaK { 18 | static const ns = "/oa"; 19 | static const credentials = "$ns/credentials"; 20 | static const lastAuthTime = "$ns/lastAuthTime"; 21 | static const loginStatus = "$ns/loginStatus"; 22 | static const userType = "$ns/userType"; 23 | } 24 | 25 | class _Oa { 26 | final Box box; 27 | 28 | _Oa(this.box); 29 | 30 | Credential? get credentials => box.safeGet(_OaK.credentials); 31 | 32 | set credentials(Credential? newV) => box.safePut(_OaK.credentials, newV); 33 | 34 | late final $credentials = box.provider(_OaK.credentials); 35 | 36 | DateTime? get lastAuthTime => box.safeGet(_OaK.lastAuthTime); 37 | 38 | set lastAuthTime(DateTime? newV) => box.safePut(_OaK.lastAuthTime, newV); 39 | 40 | late final $lastAuthTime = box.provider(_OaK.lastAuthTime); 41 | 42 | OaLoginStatus? get loginStatus => box.safeGet(_OaK.loginStatus) ?? OaLoginStatus.never; 43 | 44 | set loginStatus(OaLoginStatus? newV) => box.safePut(_OaK.loginStatus, newV); 45 | 46 | late final $loginStatus = box.providerWithDefault(_OaK.loginStatus, () => OaLoginStatus.never); 47 | 48 | OaUserType get userType => box.safeGet(_OaK.userType) ?? OaUserType.none; 49 | 50 | set userType(OaUserType newV) => box.safePut(_OaK.userType, newV); 51 | 52 | late final $userType = box.providerWithDefault(_OaK.userType, () => OaUserType.none); 53 | } 54 | -------------------------------------------------------------------------------- /lib/credentials/utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/credentials/entity/user_type.dart'; 2 | 3 | /// 本、专科生(10位学号) 4 | final _undergraduateId = RegExp(r'^(\d{6}[YGHE\d]\d{3})$'); 5 | 6 | /// 研究生(9位学号) 7 | final _postgraduateId = RegExp(r'^(\d{2}6\d{6})$'); 8 | 9 | /// 教师(4位工号) 10 | final _teacherId = RegExp(r'^(\d{4})$'); 11 | 12 | /// 新生(14位纯数字高考报名号) 13 | final _freshmanId = RegExp(r'^(\d{14})$'); 14 | 15 | /// [schoolId] can be a student ID or a work number. 16 | OaUserType? estimateOaUserType(String schoolId) { 17 | if (schoolId.length == 10 && _undergraduateId.hasMatch(schoolId.toUpperCase())) { 18 | return OaUserType.undergraduate; 19 | } else if (schoolId.length == 9 && _postgraduateId.hasMatch(schoolId)) { 20 | return OaUserType.postgraduate; 21 | } else if (schoolId.length == 4 && _teacherId.hasMatch(schoolId)) { 22 | return OaUserType.worker; 23 | } else if (schoolId.length == 14 && _freshmanId.hasMatch(schoolId)) { 24 | return OaUserType.freshman; 25 | } 26 | return null; 27 | } 28 | -------------------------------------------------------------------------------- /lib/design/adaptive/swipe.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_swipe_action_cell/flutter_swipe_action_cell.dart' as w; 3 | import 'package:mimir/design/adaptive/foundation.dart'; 4 | import 'package:rettulf/rettulf.dart'; 5 | 6 | class SwipeAction { 7 | final VoidCallback action; 8 | final IconData? icon; 9 | final String? label; 10 | final bool fullSwipeAction; 11 | final bool destructive; 12 | final Color? color; 13 | 14 | const SwipeAction({ 15 | required this.action, 16 | this.icon, 17 | this.label, 18 | this.fullSwipeAction = false, 19 | this.destructive = false, 20 | this.color = Colors.green, 21 | }); 22 | 23 | const SwipeAction.delete({ 24 | required this.action, 25 | this.icon, 26 | this.label, 27 | }) : destructive = true, 28 | fullSwipeAction = true, 29 | color = null; 30 | 31 | w.SwipeAction build(BuildContext context) { 32 | return w.SwipeAction( 33 | title: label, 34 | icon: Icon(icon, color: Colors.white), 35 | color: color ?? context.$red$, 36 | style: context.textTheme.titleSmall ?? const TextStyle(), 37 | performsFirstActionWithFullSwipe: fullSwipeAction, 38 | onTap: (w.CompletionHandler handler) async { 39 | await handler(destructive); 40 | action(); 41 | }, 42 | ); 43 | } 44 | } 45 | 46 | class WithSwipeAction extends StatelessWidget { 47 | final Widget child; 48 | final SwipeAction? left; 49 | final SwipeAction? right; 50 | final Key childKey; 51 | 52 | const WithSwipeAction({ 53 | super.key, 54 | required this.childKey, 55 | required this.child, 56 | this.left, 57 | this.right, 58 | }); 59 | 60 | @override 61 | Widget build(BuildContext context) { 62 | final left = this.left; 63 | final right = this.right; 64 | return w.SwipeActionCell( 65 | key: childKey, 66 | backgroundColor: Colors.transparent, 67 | leadingActions: left == null 68 | ? null 69 | : [ 70 | left.build(context), 71 | ], 72 | trailingActions: right == null 73 | ? null 74 | : [ 75 | right.build(context), 76 | ], 77 | child: child, 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/design/animation/animated.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | extension AnimatedEx on Widget { 4 | Widget animatedSwitched({ 5 | Duration duration = Durations.medium2, 6 | Curve? switchInCurve, 7 | Curve? switchOutCurve, 8 | }) => 9 | AnimatedSwitcher( 10 | switchInCurve: switchInCurve ?? Curves.linear, 11 | switchOutCurve: switchOutCurve ?? Curves.linear, 12 | duration: duration, 13 | child: this, 14 | ); 15 | 16 | Widget animatedSized({ 17 | Duration duration = Durations.medium2, 18 | Alignment align = Alignment.center, 19 | Curve curve = Curves.fastEaseInToSlowEaseOut, 20 | }) => 21 | AnimatedSize( 22 | curve: curve, 23 | duration: duration, 24 | alignment: align, 25 | child: this, 26 | ); 27 | } 28 | 29 | class AnimatedSwitched extends StatelessWidget { 30 | final Duration duration; 31 | final Widget? child; 32 | final Curve switchInCurve; 33 | final Curve switchOutCurve; 34 | 35 | const AnimatedSwitched({ 36 | super.key, 37 | this.duration = Durations.medium2, 38 | this.child, 39 | this.switchInCurve = Curves.linear, 40 | this.switchOutCurve = Curves.linear, 41 | }); 42 | 43 | @override 44 | Widget build(BuildContext context) { 45 | return AnimatedSwitcher( 46 | duration: duration, 47 | child: child ?? const SizedBox(), 48 | ); 49 | } 50 | } 51 | 52 | class AnimatedShowUp extends StatelessWidget { 53 | final Duration duration; 54 | final Curve switchInCurve; 55 | final Curve switchOutCurve; 56 | final bool when; 57 | final WidgetBuilder builder; 58 | 59 | const AnimatedShowUp({ 60 | super.key, 61 | this.duration = Durations.medium2, 62 | required this.when, 63 | required this.builder, 64 | this.switchInCurve = Curves.linear, 65 | this.switchOutCurve = Curves.linear, 66 | }); 67 | 68 | @override 69 | Widget build(BuildContext context) { 70 | return AnimatedSwitcher( 71 | duration: duration, 72 | child: when ? builder(context) : const SizedBox(), 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/design/animation/number.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AnimatedNumber extends StatelessWidget { 4 | final Duration duration; 5 | final num value; 6 | final Widget Function(BuildContext context, double value) builder; 7 | 8 | const AnimatedNumber({ 9 | super.key, 10 | required this.value, 11 | this.builder = _kBuild, 12 | this.duration = Durations.medium3, 13 | }); 14 | 15 | static Widget _kBuild(BuildContext context, double value) { 16 | return Text("$value"); 17 | } 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return TweenAnimationBuilder( 22 | tween: Tween(begin: value, end: value), 23 | duration: duration, 24 | builder: (ctx, value, _) => builder(ctx, value.toDouble()), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/design/widget/app.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:rettulf/rettulf.dart'; 3 | 4 | class AppCard extends StatelessWidget { 5 | /// [SizedBox] by default. 6 | final Widget? view; 7 | final Widget? leading; 8 | final Widget? title; 9 | final Widget? subtitle; 10 | final Widget? trailing; 11 | final List? leftActions; 12 | 13 | /// 12 by default. 14 | final double? leftActionsSpacing; 15 | final List? rightActions; 16 | 17 | /// 0 by default 18 | final double? rightActionsSpacing; 19 | 20 | const AppCard({ 21 | super.key, 22 | this.view, 23 | this.leading, 24 | this.title, 25 | this.subtitle, 26 | this.trailing, 27 | this.leftActions, 28 | this.rightActions, 29 | this.leftActionsSpacing, 30 | this.rightActionsSpacing, 31 | }); 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | final leftActions = this.leftActions ?? const []; 36 | final rightActions = this.rightActions ?? const []; 37 | final textTheme = context.textTheme; 38 | return Card.outlined( 39 | clipBehavior: Clip.hardEdge, 40 | child: [ 41 | AnimatedSize( 42 | duration: Durations.long2, 43 | alignment: Alignment.topCenter, 44 | curve: Curves.fastEaseInToSlowEaseOut, 45 | child: view ?? const SizedBox.shrink(), 46 | ).align(at: Alignment.center), 47 | ListTile( 48 | leading: leading, 49 | titleTextStyle: textTheme.titleLarge, 50 | title: title, 51 | subtitleTextStyle: textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant), 52 | subtitle: subtitle, 53 | trailing: trailing, 54 | ), 55 | OverflowBar( 56 | alignment: MainAxisAlignment.spaceBetween, 57 | children: [ 58 | leftActions.wrap(spacing: leftActionsSpacing ?? 8), 59 | rightActions.wrap(spacing: rightActionsSpacing ?? 0), 60 | ], 61 | ).padOnly(l: 16, b: rightActions.isEmpty ? 12 : 8, r: 16), 62 | ].column(), 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/design/widget/card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | enum CardVariant { 4 | elevated, 5 | filled, 6 | outlined; 7 | } 8 | 9 | extension WidgetCardX on Widget { 10 | Widget inAnyCard({ 11 | Clip? clip, 12 | CardVariant type = CardVariant.elevated, 13 | EdgeInsetsGeometry? margin, 14 | Color? color, 15 | }) { 16 | return switch (type) { 17 | CardVariant.elevated => Card( 18 | clipBehavior: clip, 19 | color: color, 20 | margin: margin, 21 | child: this, 22 | ), 23 | CardVariant.filled => Card.filled( 24 | clipBehavior: clip, 25 | color: color, 26 | margin: margin, 27 | child: this, 28 | ), 29 | CardVariant.outlined => Card.outlined( 30 | clipBehavior: clip, 31 | color: color, 32 | margin: margin, 33 | child: this, 34 | ), 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/design/widget/common.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_svg/svg.dart'; 3 | import 'package:rettulf/rettulf.dart'; 4 | 5 | class LeavingBlank extends StatelessWidget { 6 | final WidgetBuilder iconBuilder; 7 | final String? desc; 8 | final Widget? subtitle; 9 | final Widget? action; 10 | 11 | const LeavingBlank.builder({ 12 | super.key, 13 | required this.iconBuilder, 14 | required this.desc, 15 | this.subtitle, 16 | this.action, 17 | }); 18 | 19 | factory LeavingBlank({ 20 | Key? key, 21 | required IconData icon, 22 | String? desc, 23 | double size = 120, 24 | Widget? subtitle, 25 | Widget? action, 26 | }) { 27 | return LeavingBlank.builder( 28 | iconBuilder: (ctx) => icon.make(size: size, color: ctx.colorScheme.primary), 29 | desc: desc, 30 | subtitle: subtitle, 31 | action: action, 32 | ); 33 | } 34 | 35 | factory LeavingBlank.svgAssets({ 36 | Key? key, 37 | required String assetName, 38 | String? desc, 39 | VoidCallback? onIconTap, 40 | double width = 120, 41 | double height = 120, 42 | Widget? subtitle, 43 | Widget? action, 44 | }) { 45 | return LeavingBlank.builder( 46 | iconBuilder: (ctx) => SvgPicture.asset(assetName, width: width, height: height), 47 | desc: desc, 48 | subtitle: subtitle, 49 | action: action, 50 | ); 51 | } 52 | 53 | @override 54 | Widget build(BuildContext context) { 55 | final desc = this.desc; 56 | final action = this.action; 57 | final subtitle = this.subtitle; 58 | Widget icon = iconBuilder(context).padAll(20); 59 | return [ 60 | icon, 61 | if (desc != null) buildDesc(context, desc), 62 | if (subtitle != null) subtitle, 63 | if (action != null) action, 64 | ].column(maa: MainAxisAlignment.spaceAround, mas: MainAxisSize.min).center(); 65 | } 66 | 67 | Widget buildDesc(BuildContext ctx, String desc) { 68 | return desc 69 | .text( 70 | style: ctx.textTheme.titleLarge, 71 | textAlign: TextAlign.center, 72 | ) 73 | .center() 74 | .padAll(10); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/design/widget/list_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:rettulf/rettulf.dart'; 4 | import 'package:mimir/design/adaptive/dialog.dart'; 5 | import 'package:mimir/l10n/common.dart'; 6 | 7 | class DetailListTile extends StatelessWidget { 8 | final String? title; 9 | final String? subtitle; 10 | final Widget? leading; 11 | final Widget? trailing; 12 | final bool copyable; 13 | final bool enabled; 14 | final VoidCallback? onTap; 15 | 16 | const DetailListTile({ 17 | super.key, 18 | this.title, 19 | this.subtitle, 20 | this.copyable = true, 21 | this.leading, 22 | this.trailing, 23 | this.enabled = true, 24 | this.onTap, 25 | }); 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | final subtitle = this.subtitle; 30 | return ListTile( 31 | leading: leading, 32 | trailing: trailing, 33 | title: title?.text(), 34 | subtitle: subtitle?.text(), 35 | visualDensity: VisualDensity.compact, 36 | enabled: enabled, 37 | onTap: onTap, 38 | onLongPress: copyable && subtitle != null 39 | ? () async { 40 | final title = this.title; 41 | if (title != null) { 42 | context.showSnackBar(content: const CommonI18n().copyTipOf(title).text()); 43 | } 44 | await Clipboard.setData(ClipboardData(text: subtitle)); 45 | } 46 | : null, 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/design/widget/navigation.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:mimir/design/adaptive/multiplatform.dart'; 4 | 5 | class PageNavigationTile extends StatelessWidget { 6 | final Widget? title; 7 | final Widget? subtitle; 8 | final Widget? leading; 9 | final String path; 10 | 11 | const PageNavigationTile({ 12 | super.key, 13 | this.title, 14 | this.subtitle, 15 | this.leading, 16 | required this.path, 17 | }); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return ListTile( 22 | title: title, 23 | subtitle: subtitle, 24 | leading: leading, 25 | trailing: Icon(context.icons.rightChevron), 26 | onTap: () { 27 | context.push(path); 28 | }, 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/design/widget/tags.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:rettulf/rettulf.dart'; 3 | 4 | class TagsGroup extends StatelessWidget { 5 | final List tags; 6 | 7 | const TagsGroup( 8 | this.tags, { 9 | super.key, 10 | }); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final textTheme = context.textTheme; 15 | return tags 16 | .map( 17 | (tag) => Chip( 18 | label: tag.text(), 19 | padding: EdgeInsets.zero, 20 | labelStyle: textTheme.bodySmall, 21 | elevation: 4, 22 | ), 23 | ) 24 | .toList() 25 | .wrap(spacing: 4); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/design/widget/task_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | typedef Task = Future Function(); 4 | 5 | class TaskBuilder extends StatefulWidget { 6 | final Task? task; 7 | final void Function(dynamic error, StackTrace stackTrace)? onError; 8 | final Widget Function(BuildContext context, Task? task, bool? running) builder; 9 | 10 | const TaskBuilder({ 11 | super.key, 12 | this.task, 13 | required this.builder, 14 | this.onError, 15 | }); 16 | 17 | @override 18 | State createState() => _TaskBuilderState(); 19 | } 20 | 21 | class _TaskBuilderState extends State { 22 | var running = false; 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | final task = widget.task; 27 | return widget.builder( 28 | context, 29 | task == null || running 30 | ? null 31 | : () async { 32 | setState(() { 33 | running = true; 34 | }); 35 | try { 36 | await task(); 37 | } catch (error, stackTrace) { 38 | widget.onError?.call(error, stackTrace); 39 | } finally { 40 | if (context.mounted) { 41 | setState(() { 42 | running = false; 43 | }); 44 | } 45 | } 46 | }, 47 | running, 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/design/widget/tooltip.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class TooltipScope extends StatefulWidget { 4 | final String? message; 5 | final InlineSpan? richMessage; 6 | final Duration? showDuration; 7 | final Widget Function( 8 | BuildContext context, 9 | Widget trigger, 10 | Future Function() showTooltip, 11 | ) builder; 12 | final Widget trigger; 13 | 14 | const TooltipScope({ 15 | super.key, 16 | this.message, 17 | this.richMessage, 18 | this.showDuration, 19 | required this.trigger, 20 | required this.builder, 21 | }); 22 | 23 | @override 24 | State createState() => _TooltipState(); 25 | } 26 | 27 | class _TooltipState extends State { 28 | final $tooltip = GlobalKey(debugLabel: "Tooltip"); 29 | 30 | @override 31 | Widget build(BuildContext context) { 32 | return widget.builder( 33 | context, 34 | buildTrigger(), 35 | showTooltip, 36 | ); 37 | } 38 | 39 | Widget buildTrigger() { 40 | return Tooltip( 41 | key: $tooltip, 42 | message: widget.message, 43 | richMessage: widget.richMessage, 44 | showDuration: widget.showDuration, 45 | triggerMode: TooltipTriggerMode.tap, 46 | child: widget.trigger, 47 | ); 48 | } 49 | 50 | Future showTooltip() async { 51 | $tooltip.currentState?.ensureTooltipVisible(); 52 | await Future.delayed(widget.showDuration ?? const Duration(milliseconds: 1500)); 53 | Tooltip.dismissAllToolTips(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/entity/campus.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:mimir/storage/hive/type_id.dart'; 3 | 4 | part 'campus.g.dart'; 5 | 6 | @HiveType(typeId: CoreHiveType.campus) 7 | enum Campus { 8 | @HiveField(0) 9 | fengxian(), 10 | @HiveField(1) 11 | xuhui(); 12 | 13 | const Campus(); 14 | 15 | String l10n() => "campus.$name".tr(); 16 | } 17 | -------------------------------------------------------------------------------- /lib/entity/campus.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'campus.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class CampusAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 3; 12 | 13 | @override 14 | Campus read(BinaryReader reader) { 15 | switch (reader.readByte()) { 16 | case 0: 17 | return Campus.fengxian; 18 | case 1: 19 | return Campus.xuhui; 20 | default: 21 | return Campus.fengxian; 22 | } 23 | } 24 | 25 | @override 26 | void write(BinaryWriter writer, Campus obj) { 27 | switch (obj) { 28 | case Campus.fengxian: 29 | writer.writeByte(0); 30 | break; 31 | case Campus.xuhui: 32 | writer.writeByte(1); 33 | break; 34 | } 35 | } 36 | 37 | @override 38 | int get hashCode => typeId.hashCode; 39 | 40 | @override 41 | bool operator ==(Object other) => 42 | identical(this, other) || other is CampusAdapter && runtimeType == other.runtimeType && typeId == other.typeId; 43 | } 44 | -------------------------------------------------------------------------------- /lib/entity/uuid.dart: -------------------------------------------------------------------------------- 1 | abstract class WithUuid { 2 | String get uuid; 3 | } 4 | -------------------------------------------------------------------------------- /lib/files.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:path/path.dart'; 5 | import 'package:mimir/r.dart'; 6 | import 'package:universal_platform/universal_platform.dart'; 7 | 8 | class Files { 9 | const Files._(); 10 | 11 | static Future init({ 12 | required Directory temp, 13 | required Directory cache, 14 | required Directory internal, 15 | required Directory user, 16 | }) async { 17 | Files._temp = temp; 18 | Files.cache = cache; 19 | Files.internal = internal; 20 | Files.user = user; 21 | debugPrint("Cache dir: $cache"); 22 | debugPrint("Temp dir: $temp"); 23 | debugPrint("Internal dir: $internal"); 24 | debugPrint("User dir: $user"); 25 | Files.temp = TempDir._(); 26 | } 27 | 28 | static late final Directory _temp; 29 | static late final TempDir temp; 30 | static late final Directory cache; 31 | static late final Directory internal; 32 | static late final Directory user; 33 | 34 | static const oaAnnounce = OaAnnounceFiles._(); 35 | } 36 | 37 | extension DirectoryX on Directory { 38 | File subFile(String p1, [String? p2, String? p3, String? p4]) => File(join(path, p1, p2, p3, p4)); 39 | 40 | Directory subDir(String p1, [String? p2, String? p3, String? p4]) => Directory(join(path, p1, p2, p3, p4)); 41 | } 42 | 43 | class OaAnnounceFiles { 44 | const OaAnnounceFiles._(); 45 | 46 | Directory attachmentDir(String uuid) => Files.internal.subDir("attachment", uuid); 47 | 48 | Future init() async {} 49 | } 50 | 51 | class TempDir { 52 | TempDir._(); 53 | 54 | Directory? _realDir = UniversalPlatform.isWindows ? null : Files._temp; 55 | 56 | Directory dir() { 57 | // lazy create temp dir 58 | return _realDir ??= Files._temp.createTempSync(R.appId); 59 | } 60 | 61 | File subFile(String p1, [String? p2, String? p3, String? p4]) => dir().subFile(p1, p2, p3, p4); 62 | 63 | Directory subDir(String p1, [String? p2, String? p3, String? p4]) => dir().subDir(p1, p2, p3, p4); 64 | } 65 | -------------------------------------------------------------------------------- /lib/l10n/app.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/timetable/i18n.dart' as t; 2 | import 'package:mimir/school/i18n.dart' as s; 3 | 4 | class AppI18n { 5 | const AppI18n(); 6 | final navigation = const _Navigation(); 7 | } 8 | 9 | class _Navigation { 10 | const _Navigation(); 11 | 12 | String get timetable => t.i18n.navigation; 13 | String get school => s.i18n.navigation; 14 | } 15 | -------------------------------------------------------------------------------- /lib/l10n/extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:mimir/l10n/time.dart'; 4 | 5 | import 'lang.dart'; 6 | 7 | export 'package:mimir/r.dart'; 8 | 9 | export 'lang.dart'; 10 | 11 | extension I18nBuildContext on BuildContext { 12 | ///e.g.: Wednesday, September 21, 2022 13 | String formatYmdWeekText(DateTime date) => Lang.formatOf(locale).ymdWeekText.format(date); 14 | 15 | ///e.g.: Wednesday, September 21 16 | String formatMdWeekText(DateTime date) => Lang.formatOf(locale).mdWeekText.format(date); 17 | 18 | ///e.g.: September 21, 2022 19 | String formatYmdText(DateTime date) => Lang.formatOf(locale).ymdText.format(date); 20 | 21 | ///e.g.: 9/21/2022 22 | String formatYmdNum(DateTime date) => Lang.formatOf(locale).ymdNum.format(date); 23 | 24 | ///e.g.: 9/21/2022 23:57:23 25 | String formatYmdhmsNum(DateTime date) => Lang.formatOf(locale).ymdhmsNum.format(date); 26 | 27 | ///e.g.: 9/21/2022 23:57 28 | String formatYmdhmNum(DateTime date) => Lang.formatOf(locale).ymdhmNum.format(date); 29 | 30 | String formatYmText(DateTime date) => Lang.formatOf(locale).ymText.format(date); 31 | 32 | /// e.g.: 8:32:59 33 | String formatHmsNum(DateTime date) => Lang.formatOf(locale).hms.format(date); 34 | 35 | /// e.g.: 8:32 36 | String formatHmNum(DateTime date) => Lang.formatOf(locale).hm.format(date); 37 | 38 | /// e.g.: 9/21 39 | String formatMdNum(DateTime date) => Lang.formatOf(locale).mdNum.format(date); 40 | 41 | /// e.g.: 9/21 7:32 42 | String formatMdhmNum(DateTime date) => Lang.formatOf(locale).mdHmNum.format(date); 43 | 44 | Weekday firstDayInWeek() => Lang.formatOf(locale).firstDayInWeek; 45 | } 46 | 47 | extension BrightnessL10nX on Brightness { 48 | String l10n() => "brightness.$name".tr(); 49 | } 50 | 51 | extension ThemeModeL10nX on ThemeMode { 52 | String l10n() => "themeMode.$name".tr(); 53 | } 54 | -------------------------------------------------------------------------------- /lib/l10n/lang.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:intl/intl.dart'; 4 | import 'package:mimir/l10n/time.dart'; 5 | import 'package:mimir/r.dart'; 6 | 7 | abstract class RegionalFormatter { 8 | DateFormat get hms; 9 | 10 | DateFormat get hm; 11 | 12 | DateFormat get ymdText; 13 | 14 | DateFormat get ymdWeekText; 15 | 16 | DateFormat get mdWeekText; 17 | 18 | DateFormat get ymText; 19 | 20 | DateFormat get ymdNum; 21 | 22 | DateFormat get ymdhmsNum; 23 | 24 | DateFormat get ymdhmNum; 25 | 26 | DateFormat get mdHmNum; 27 | 28 | DateFormat get mdNum; 29 | 30 | Weekday get firstDayInWeek; 31 | } 32 | 33 | class Lang { 34 | Lang._(); 35 | 36 | static final zhHansFormatter = _ZhHansFormatter(); 37 | static final locale2Format = { 38 | R.zhHansLocale: _ZhHansFormatter(), 39 | }; 40 | 41 | static RegionalFormatter formatOf(Locale locale) => locale2Format[locale] ?? zhHansFormatter; 42 | } 43 | 44 | class _ZhHansFormatter implements RegionalFormatter { 45 | @override 46 | final hms = DateFormat("H:mm:ss"); 47 | @override 48 | final hm = DateFormat("H:mm"); 49 | @override 50 | final ymdText = DateFormat("yyyy年M月d日", "zh_Hans"); 51 | @override 52 | final ymdWeekText = DateFormat("yyyy年M月d日 EEEE", "zh_Hans"); 53 | @override 54 | final mdWeekText = DateFormat("M月d日 EEEE", "zh_Hans"); 55 | @override 56 | final ymText = DateFormat("yyyy年M月", "zh_Hans"); 57 | @override 58 | final ymdNum = DateFormat("yyyy/M/d", "zh_Hans"); 59 | @override 60 | final ymdhmsNum = DateFormat("yyyy/M/d H:mm:ss", "zh_Hans"); 61 | @override 62 | final ymdhmNum = DateFormat("yyyy/M/d H:mm:ss", "zh_Hans"); 63 | @override 64 | final mdHmNum = DateFormat("M/d H:mm", "zh_Hans"); 65 | @override 66 | final mdNum = DateFormat("M/d", "zh_Hans"); 67 | @override 68 | final firstDayInWeek = Weekday.monday; 69 | } 70 | -------------------------------------------------------------------------------- /lib/l10n/time.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:json_annotation/json_annotation.dart'; 3 | 4 | @JsonEnum() 5 | enum Weekday { 6 | monday, 7 | tuesday, 8 | wednesday, 9 | thursday, 10 | friday, 11 | saturday, 12 | sunday; 13 | 14 | int toJson() => index; 15 | 16 | static const calendarOrder = [ 17 | sunday, 18 | monday, 19 | tuesday, 20 | wednesday, 21 | thursday, 22 | friday, 23 | saturday, 24 | ]; 25 | 26 | int getIndex({required Weekday firstDay}) { 27 | return (this - firstDay.index).index; 28 | } 29 | 30 | const Weekday(); 31 | static Weekday fromJson(int json) => Weekday.values.elementAtOrNull(json) ?? Weekday.monday; 32 | 33 | static Weekday fromIndex(int index) { 34 | assert(0 <= index && index < Weekday.values.length); 35 | return Weekday.values[index % Weekday.values.length]; 36 | } 37 | 38 | String l10n() => "weekday.$index".tr(); 39 | 40 | String l10nShort() => "weekdayShort.$index".tr(); 41 | 42 | static List genSequence(Weekday firstDay) { 43 | return List.generate(7, (index) => firstDay + index); 44 | } 45 | 46 | Weekday operator +(int delta) { 47 | return Weekday.values[(index + delta) % Weekday.values.length]; 48 | } 49 | 50 | Weekday operator -(int delta) { 51 | return Weekday.values[(index - delta) % Weekday.values.length]; 52 | } 53 | 54 | List genSequenceStartWithThis() => genSequence(this); 55 | } 56 | -------------------------------------------------------------------------------- /lib/l10n/tr.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:flutter/widgets.dart'; 3 | 4 | extension TrX on String { 5 | List trSpan({ 6 | BuildContext? context, 7 | required Map args, 8 | }) { 9 | final translated = this.tr( 10 | namedArgs: args.map((k, v) => MapEntry(k, "{$k}")), 11 | ); 12 | return replaceWidget(raw: translated, args: args); 13 | } 14 | } 15 | 16 | List replaceWidget({ 17 | required String raw, 18 | required Map args, 19 | }) { 20 | List spans = []; 21 | RegExp regExp = RegExp(r'{(.*?)}'); 22 | Iterable matches = regExp.allMatches(raw); 23 | int currentIndex = 0; 24 | 25 | for (Match match in matches) { 26 | spans.add(TextSpan(text: raw.substring(currentIndex, match.start))); 27 | final key = match.group(1); 28 | if (key == null) { 29 | spans.add(const TextSpan(text: "?")); 30 | } else { 31 | final replaced = args[key]; 32 | if (replaced == null) { 33 | spans.add(TextSpan(text: key)); 34 | } else { 35 | spans.add(replaced); 36 | } 37 | } 38 | currentIndex = match.end; 39 | } 40 | 41 | if (currentIndex < raw.length) { 42 | spans.add(TextSpan(text: raw.substring(currentIndex))); 43 | } 44 | return spans; 45 | } 46 | -------------------------------------------------------------------------------- /lib/l10n/yaml_assets_loader.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | import 'dart:ui'; 3 | 4 | import 'package:easy_localization/easy_localization.dart'; 5 | import 'package:flutter/services.dart'; 6 | import 'package:yaml/yaml.dart'; 7 | 8 | //Loader for multiple yaml files 9 | class YamlAssetLoader extends AssetLoader { 10 | String getLocalePath(String basePath, Locale locale) { 11 | return '$basePath/${locale.toStringWithSeparator(separator: "-")}.yaml'; 12 | } 13 | 14 | @override 15 | Future> load(String path, Locale locale) async { 16 | var localePath = getLocalePath(path, locale); 17 | log('easy localization loader: load yaml file $localePath'); 18 | YamlMap yaml = loadYaml(await rootBundle.loadString(localePath)); 19 | return convertYamlMapToMap(yaml); 20 | } 21 | } 22 | 23 | //Loader for single yaml file 24 | class YamlSingleAssetLoader extends AssetLoader { 25 | Map? yamlData; 26 | 27 | @override 28 | Future> load(String path, Locale locale) async { 29 | if (yamlData == null) { 30 | log('easy localization loader: load yaml file $path'); 31 | yamlData = convertYamlMapToMap(loadYaml(await rootBundle.loadString(path))); 32 | } else { 33 | log('easy localization loader: Yaml already loaded, read cache'); 34 | } 35 | return yamlData![locale.toString()]; 36 | } 37 | } 38 | 39 | /// Convert YamlMap to Map 40 | Map convertYamlMapToMap(YamlMap yamlMap) { 41 | final map = {}; 42 | 43 | for (final entry in yamlMap.entries) { 44 | if (entry.value is YamlMap || entry.value is Map) { 45 | map[entry.key.toString()] = convertYamlMapToMap(entry.value); 46 | } else { 47 | map[entry.key.toString()] = entry.value.toString(); 48 | } 49 | } 50 | return map; 51 | } 52 | -------------------------------------------------------------------------------- /lib/lifecycle.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | final $key = GlobalKey(); 4 | -------------------------------------------------------------------------------- /lib/login/i18n.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:mimir/credentials/i18n.dart'; 3 | import 'package:mimir/l10n/common.dart'; 4 | 5 | class CommonAuthI18n with CommonAuthI18nMixin, CommonI18nMixin { 6 | const CommonAuthI18n(); 7 | } 8 | 9 | mixin class CommonAuthI18nMixin { 10 | static const ns = "auth"; 11 | 12 | String get signIn => "$ns.signIn".tr(); 13 | 14 | String get signUp => "$ns.signUp".tr(); 15 | 16 | String get signOut => "$ns.signOut".tr(); 17 | 18 | String get login => "$ns.login".tr(); 19 | 20 | String get forgotPwd => "$ns.forgotPwd".tr(); 21 | 22 | String get formatError => "$ns.formatError".tr(); 23 | 24 | String get validateInputAccountPwdRequest => "$ns.validateInputAccountPwdRequest".tr(); 25 | 26 | String get invalidAccountFormat => "$ns.invalidAccountFormat".tr(); 27 | 28 | String get offlineModeBtn => "$ns.offlineModeBtn".tr(); 29 | 30 | String get failedWarn => "$ns.failedWarn".tr(); 31 | 32 | String get unknownAuthErrorTip => "$ns.unknownAuthErrorTip".tr(); 33 | } 34 | 35 | class OaLoginI18n extends CommonAuthI18n { 36 | const OaLoginI18n(); 37 | 38 | static const ns = "oa.login"; 39 | 40 | final credentials = const OaCredentialsI18n(); 41 | 42 | String get welcomeHeader => "$ns.welcomeHeader".tr(); 43 | 44 | String get loginOa => "$ns.loginOa".tr(); 45 | 46 | String get accountHint => "$ns.accountHint".tr(); 47 | 48 | String get oaPwdHint => "$ns.oaPwdHint".tr(); 49 | 50 | String get freshmanSystemPwd => "$ns.freshmanSystemPwd".tr(); 51 | 52 | String get freshmanSystemPwdHint => "$ns.freshmanSystemPwdHint".tr(); 53 | 54 | String get schoolServerUnconnectedTip => "$ns.schoolServerUnconnectedTip".tr(); 55 | 56 | String get loginRequired => "$ns.loginRequired".tr(); 57 | 58 | String get neverLoggedInTip => "$ns.neverLoggedInTip".tr(); 59 | 60 | String get disclaimer => "$ns.disclaimer".tr(); 61 | 62 | String get freshmanTip => "$ns.freshmanTip".tr(); 63 | 64 | String get freshmanSystemTip => "$ns.freshmanSystemTip".tr(); 65 | } 66 | -------------------------------------------------------------------------------- /lib/login/init.dart: -------------------------------------------------------------------------------- 1 | import 'service/authserver.dart'; 2 | 3 | class LoginInit { 4 | static late AuthServerService authServerService; 5 | 6 | static void init() { 7 | authServerService = const AuthServerService(); 8 | } 9 | 10 | static void initStorage() {} 11 | } 12 | -------------------------------------------------------------------------------- /lib/login/page/captcha.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:easy_localization/easy_localization.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; 6 | import 'package:rettulf/rettulf.dart'; 7 | import 'package:mimir/design/adaptive/foundation.dart'; 8 | import 'package:mimir/l10n/common.dart'; 9 | 10 | const _i18n = CaptchaI18n(); 11 | 12 | class CaptchaI18n with CommonI18nMixin { 13 | static const ns = "captcha"; 14 | 15 | const CaptchaI18n(); 16 | 17 | String get title => "$ns.title".tr(); 18 | 19 | String get enterHint => "$ns.enterHint".tr(); 20 | 21 | String get emptyInputError => "$ns.emptyInputError".tr(); 22 | } 23 | 24 | class CaptchaSheetPage extends StatefulWidget { 25 | final Uint8List captchaData; 26 | 27 | const CaptchaSheetPage({ 28 | super.key, 29 | required this.captchaData, 30 | }); 31 | 32 | @override 33 | State createState() => _CaptchaSheetPageState(); 34 | } 35 | 36 | class _CaptchaSheetPageState extends State { 37 | final $captcha = TextEditingController(); 38 | 39 | @override 40 | Widget build(BuildContext context) { 41 | return Scaffold( 42 | appBar: AppBar( 43 | title: "Captcha".text(), 44 | actions: [ 45 | PlatformTextButton( 46 | onPressed: () { 47 | context.navigator.pop($captcha.text); 48 | }, 49 | child: "Submit".text(), 50 | ) 51 | ], 52 | ), 53 | body: [ 54 | Image.memory( 55 | widget.captchaData, 56 | scale: 0.5, 57 | ), 58 | $TextField$( 59 | controller: $captcha, 60 | autofocus: true, 61 | placeholder: _i18n.enterHint, 62 | keyboardType: TextInputType.text, 63 | autofillHints: const [AutofillHints.oneTimeCode], 64 | onSubmit: (value) { 65 | context.navigator.pop(value); 66 | }, 67 | ).padOnly(t: 15), 68 | ].column(mas: MainAxisSize.min).center().padH(16), 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/login/page/sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// A sheet for login auth 4 | class LoginSheet extends StatefulWidget { 5 | const LoginSheet({super.key}); 6 | 7 | @override 8 | State createState() => _LoginSheetState(); 9 | } 10 | 11 | class _LoginSheetState extends State { 12 | @override 13 | Widget build(BuildContext context) { 14 | return const Scaffold(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/login/service/authserver.dart: -------------------------------------------------------------------------------- 1 | import 'package:beautiful_soup_dart/beautiful_soup.dart'; 2 | import 'package:mimir/init.dart'; 3 | 4 | import 'package:mimir/session/sso.dart'; 5 | import 'package:mimir/utils/error.dart'; 6 | 7 | class AuthServerService { 8 | SsoSession get _session => Init.ssoSession; 9 | 10 | const AuthServerService(); 11 | 12 | Future getPersonName() async { 13 | try { 14 | final response = await _session.request('https://authserver.sit.edu.cn/authserver/index.do'); 15 | final html = BeautifulSoup(response.data); 16 | final resultDesktop = html.find('div', class_: 'auth_username')?.text ?? ''; 17 | final resultMobile = html.find('div', class_: 'index-nav-name')?.text ?? ''; 18 | 19 | final result = (resultMobile + resultDesktop).trim(); 20 | return result.isNotEmpty ? result : null; 21 | } catch (error, stackTrace) { 22 | debugPrintError(error, stackTrace); 23 | return null; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/login/utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:flutter/cupertino.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:mimir/credentials/error.dart'; 5 | import 'package:mimir/design/adaptive/dialog.dart'; 6 | import 'package:mimir/session/sso.dart'; 7 | import 'package:mimir/utils/error.dart'; 8 | import 'package:mimir/widget/markdown.dart'; 9 | import "./i18n.dart"; 10 | 11 | const _i18n = OaLoginI18n(); 12 | 13 | Future handleLoginException({ 14 | required BuildContext context, 15 | required Exception error, 16 | required StackTrace stackTrace, 17 | }) async { 18 | debugPrintError(error, stackTrace); 19 | if (!context.mounted) return; 20 | if (error is CredentialException) { 21 | await context.showAnyTip( 22 | serious: true, 23 | title: _i18n.failedWarn, 24 | desc: (ctx) => FeaturedMarkdownWidget(data: error.type.l10n()), 25 | primary: _i18n.close, 26 | ); 27 | return; 28 | } 29 | if (error is DioException) { 30 | await context.showTip( 31 | serious: true, 32 | title: _i18n.failedWarn, 33 | desc: _i18n.schoolServerUnconnectedTip, 34 | primary: _i18n.close, 35 | ); 36 | return; 37 | } 38 | if (error is LoginCancelledException) { 39 | if (!context.mounted) return; 40 | return; 41 | } 42 | if (!context.mounted) return; 43 | await context.showTip( 44 | serious: true, 45 | title: _i18n.failedWarn, 46 | desc: _i18n.unknownAuthErrorTip, 47 | primary: _i18n.close, 48 | ); 49 | return; 50 | } 51 | 52 | Future handleOaPasswordIncorrectException({ 53 | required BuildContext context, 54 | }) async { 55 | final confirm = await context.showActionRequest( 56 | destructive: true, 57 | dismissible: false, 58 | title: _i18n.credentials.credentialsError, 59 | desc: _i18n.credentials.oaPwdChangedOutsideRequest, 60 | action: _i18n.credentials.goSettings, 61 | cancel: _i18n.cancel, 62 | ); 63 | if (confirm == true) { 64 | if (!context.mounted) return; 65 | context.push("/settings/oa"); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/login/widget/forgot_pwd.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; 3 | import 'package:rettulf/rettulf.dart'; 4 | import 'package:mimir/utils/guard_launch.dart'; 5 | import '../i18n.dart'; 6 | 7 | const _i18n = OaLoginI18n(); 8 | 9 | class ForgotPasswordButton extends StatelessWidget { 10 | final String url; 11 | 12 | const ForgotPasswordButton({ 13 | super.key, 14 | required this.url, 15 | }); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return PlatformTextButton( 20 | child: _i18n.forgotPwd.text( 21 | style: const TextStyle(color: Colors.grey), 22 | ), 23 | onPressed: () async { 24 | await guardLaunchUrlString(context, url); 25 | }, 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/login/x.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/credentials/entity/credential.dart'; 2 | import 'package:mimir/credentials/entity/login_status.dart'; 3 | import 'package:mimir/credentials/entity/user_type.dart'; 4 | import 'package:mimir/credentials/init.dart'; 5 | import 'package:mimir/credentials/utils.dart'; 6 | import 'package:mimir/init.dart'; 7 | import 'package:mimir/settings/meta.dart'; 8 | import 'package:mimir/settings/settings.dart'; 9 | 10 | import 'init.dart'; 11 | 12 | class XLogin { 13 | static Future login(Credential credentials) async { 14 | credentials = credentials.copyWith( 15 | account: credentials.account.toUpperCase(), 16 | ); 17 | final userType = estimateOaUserType(credentials.account); 18 | await Init.ssoSession.deleteSitUriCookies(); 19 | await Init.ssoSession.loginLocked(credentials, active: true); 20 | // set user's real name to signature by default. 21 | final personName = await LoginInit.authServerService.getPersonName(); 22 | Meta.userRealName ??= personName; 23 | Settings.lastSignature ??= personName; 24 | CredentialsInit.storage.oa.credentials = credentials; 25 | CredentialsInit.storage.oa.loginStatus = OaLoginStatus.validated; 26 | CredentialsInit.storage.oa.lastAuthTime = DateTime.now(); 27 | CredentialsInit.storage.oa.userType = userType ?? OaUserType.none; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/school/electricity/entity/balance.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:mimir/storage/hive/type_id.dart'; 3 | 4 | part 'balance.g.dart'; 5 | 6 | double _parseBalance(String raw) { 7 | return double.tryParse(raw) ?? 0; 8 | } 9 | 10 | /// 0.636 RMB/kWh 11 | /// Since 1/1/2024, it rises from 0.61 to 0.636. 12 | const rmbPerKwh = 0.636; 13 | 14 | /// ```json 15 | /// [{ 16 | /// "RoomName":"105604", 17 | /// "BaseBalance":"53.6640", 18 | /// "ElecBalance":"0.0000", 19 | /// "Balance":"53.6640" 20 | /// }] 21 | /// ``` 22 | @JsonSerializable(createToJson: false) 23 | @HiveType(typeId: CacheHiveType.electricityBalance) 24 | class ElectricityBalance { 25 | @JsonKey(name: "Balance", fromJson: _parseBalance) 26 | @HiveField(0) 27 | final double balance; 28 | 29 | @JsonKey(name: "BaseBalance", fromJson: _parseBalance) 30 | @HiveField(1) 31 | final double baseBalance; 32 | 33 | @JsonKey(name: "ElecBalance", fromJson: _parseBalance) 34 | @HiveField(2) 35 | final double electricityBalance; 36 | 37 | @JsonKey(name: "RoomName") 38 | @HiveField(3) 39 | final String roomNumber; 40 | 41 | const ElectricityBalance({ 42 | required this.roomNumber, 43 | required this.balance, 44 | required this.baseBalance, 45 | required this.electricityBalance, 46 | }); 47 | 48 | const ElectricityBalance.all({ 49 | required this.roomNumber, 50 | required this.balance, 51 | }) : baseBalance = balance, 52 | electricityBalance = balance; 53 | 54 | factory ElectricityBalance.fromJson(Map json) => _$ElectricityBalanceFromJson(json); 55 | 56 | double get remainingPower => balance / rmbPerKwh; 57 | 58 | @override 59 | String toString() { 60 | return { 61 | "balance": balance, 62 | "baseBalance": baseBalance, 63 | "electricityBalance": electricityBalance, 64 | "roomNumber": roomNumber, 65 | }.toString(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/school/electricity/entity/balance.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'balance.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class ElectricityBalanceAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 60; 12 | 13 | @override 14 | ElectricityBalance read(BinaryReader reader) { 15 | final numOfFields = reader.readByte(); 16 | final fields = { 17 | for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), 18 | }; 19 | return ElectricityBalance( 20 | roomNumber: fields[3] as String, 21 | balance: fields[0] as double, 22 | baseBalance: fields[1] as double, 23 | electricityBalance: fields[2] as double, 24 | ); 25 | } 26 | 27 | @override 28 | void write(BinaryWriter writer, ElectricityBalance obj) { 29 | writer 30 | ..writeByte(4) 31 | ..writeByte(0) 32 | ..write(obj.balance) 33 | ..writeByte(1) 34 | ..write(obj.baseBalance) 35 | ..writeByte(2) 36 | ..write(obj.electricityBalance) 37 | ..writeByte(3) 38 | ..write(obj.roomNumber); 39 | } 40 | 41 | @override 42 | int get hashCode => typeId.hashCode; 43 | 44 | @override 45 | bool operator ==(Object other) => 46 | identical(this, other) || 47 | other is ElectricityBalanceAdapter && runtimeType == other.runtimeType && typeId == other.typeId; 48 | } 49 | 50 | // ************************************************************************** 51 | // JsonSerializableGenerator 52 | // ************************************************************************** 53 | 54 | ElectricityBalance _$ElectricityBalanceFromJson(Map json) => ElectricityBalance( 55 | roomNumber: json['RoomName'] as String, 56 | balance: _parseBalance(json['Balance'] as String), 57 | baseBalance: _parseBalance(json['BaseBalance'] as String), 58 | electricityBalance: _parseBalance(json['ElecBalance'] as String), 59 | ); 60 | -------------------------------------------------------------------------------- /lib/school/electricity/i18n.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:mimir/l10n/common.dart'; 3 | 4 | const i18n = _I18n(); 5 | 6 | class _I18n with CommonI18nMixin { 7 | const _I18n(); 8 | 9 | static const ns = "electricity"; 10 | final unit = const UnitI18n(); 11 | 12 | String get title => "$ns.title".tr(); 13 | 14 | String get searchRoom => "$ns.searchRoom".tr(); 15 | 16 | String get balance => "$ns.balance".tr(); 17 | 18 | String get remainingPower => "$ns.remainingPower".tr(); 19 | 20 | String get searchInvalidTip => "$ns.searchInvalidTip".tr(); 21 | 22 | String get refreshSuccessTip => "$ns.refreshSuccessTip".tr(); 23 | 24 | String get refreshFailedTip => "$ns.refreshFailedTip".tr(); 25 | 26 | String get emptyHistoryTip => "$ns.emptyHistoryTip".tr(); 27 | 28 | String get noMatchedRoomNumbers => "$ns.noMatchedRoomNumbers".tr(); 29 | 30 | String lastUpdateTime(String time) => "$ns.lastUpdateTime".tr(args: [time]); 31 | } 32 | -------------------------------------------------------------------------------- /lib/school/electricity/init.dart: -------------------------------------------------------------------------------- 1 | import 'service/electricity.dart'; 2 | import 'storage/electricity.dart'; 3 | 4 | class ElectricityBalanceInit { 5 | static late ElectricityStorage storage; 6 | static late ElectricityService service; 7 | 8 | static void init() { 9 | service = const ElectricityService(); 10 | } 11 | 12 | static void initStorage() { 13 | storage = ElectricityStorage(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/school/electricity/service/electricity.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:mimir/init.dart'; 5 | import 'package:mimir/session/ywb.dart'; 6 | import '../entity/balance.dart'; 7 | 8 | const _balanceUrl = "${YwbSession.base}/unifri-flow/WF/Comm/ProcessRequest.do?DoType=DBAccess_RunSQLReturnTable"; 9 | 10 | class ElectricityService { 11 | Dio get _dio => Init.schoolDio; 12 | 13 | const ElectricityService(); 14 | 15 | Future getBalance(String room) async { 16 | final response = await _dio.post( 17 | _balanceUrl, 18 | queryParameters: { 19 | "SQL": "select * from sys_room_balance where RoomName='$room';", 20 | }, 21 | options: Options( 22 | headers: { 23 | "Cookie": "FK_Dept=B1101", 24 | }, 25 | ), 26 | ); 27 | final data = jsonDecode(response.data as String) as List; 28 | final list = data.map((e) => ElectricityBalance.fromJson(e)).toList(); 29 | return list.first; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/school/electricity/storage/electricity.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:hive_flutter/hive_flutter.dart'; 5 | import 'package:mimir/utils/hive.dart'; 6 | import 'package:mimir/storage/hive/init.dart'; 7 | 8 | import '../entity/balance.dart'; 9 | 10 | class _K { 11 | static const lastBalance = "/lastBalance"; 12 | static const searchHistory = "/searchHistory"; 13 | static const lastUpdateTime = "/lastUpdateTime"; 14 | } 15 | 16 | class ElectricityStorage { 17 | Box get box => HiveInit.electricity; 18 | final int maxHistoryLength; 19 | 20 | ElectricityStorage({ 21 | this.maxHistoryLength = 20, 22 | }); 23 | 24 | ValueListenable listenBalance() => box.listenable(keys: [_K.lastBalance]); 25 | 26 | late final $lastBalance = box.provider(_K.lastBalance); 27 | 28 | ElectricityBalance? get lastBalance => box.safeGet(_K.lastBalance); 29 | 30 | set lastBalance(ElectricityBalance? newV) => box.safePut(_K.lastBalance, newV); 31 | 32 | List? get searchHistory => box.safeGet(_K.searchHistory); 33 | 34 | set searchHistory(List? newV) { 35 | if (newV != null) { 36 | newV = newV.sublist(0, min(newV.length, maxHistoryLength)); 37 | } 38 | box.safePut(_K.searchHistory, newV); 39 | } 40 | 41 | DateTime? get lastUpdateTime => box.safeGet(_K.lastUpdateTime); 42 | 43 | set lastUpdateTime(DateTime? newV) => box.safePut(_K.lastUpdateTime, newV); 44 | 45 | late final $lastUpdateTime = box.provider(_K.lastUpdateTime); 46 | 47 | ValueListenable listenLastUpdateTime() => box.listenable(keys: [_K.lastUpdateTime]); 48 | } 49 | -------------------------------------------------------------------------------- /lib/school/electricity/widget/card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mimir/design/animation/number.dart'; 3 | import '../entity/balance.dart'; 4 | import 'package:rettulf/rettulf.dart'; 5 | 6 | import '../i18n.dart'; 7 | 8 | class ElectricityBalanceCard extends StatelessWidget { 9 | final ElectricityBalance balance; 10 | final double? warningBalance; 11 | final Color warningColor; 12 | 13 | const ElectricityBalanceCard({ 14 | super.key, 15 | required this.balance, 16 | this.warningBalance = 10.0, 17 | this.warningColor = Colors.redAccent, 18 | }); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final warningBalance = this.warningBalance; 23 | final balance = this.balance; 24 | final balanceColor = warningBalance == null || warningBalance < balance.balance ? null : warningColor; 25 | final style = context.textTheme.titleMedium; 26 | return [ 27 | ListTile( 28 | leading: const Icon(Icons.offline_bolt), 29 | titleTextStyle: style, 30 | title: i18n.remainingPower.text(), 31 | trailing: AnimatedNumber( 32 | value: balance.remainingPower, 33 | builder: (ctx, value) => i18n.unit.powerKwh(value.toStringAsFixed(2)).text(style: style), 34 | ), 35 | ), 36 | ListTile( 37 | leading: Icon(Icons.savings, color: balanceColor), 38 | titleTextStyle: style?.copyWith(color: balanceColor), 39 | title: i18n.balance.text(), 40 | trailing: AnimatedNumber( 41 | value: balance.balance, 42 | builder: (ctx, value) => i18n.unit.rmb(value.toStringAsFixed(2)).text( 43 | style: style?.copyWith(color: balanceColor), 44 | ), 45 | ), 46 | ), 47 | ].column(maa: MainAxisAlignment.spaceEvenly).inCard(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/school/electricity/x.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/school/electricity/init.dart'; 2 | import 'package:mimir/settings/settings.dart'; 3 | 4 | class XElectricity { 5 | static void addSearchHistory(String room) { 6 | final storage = ElectricityBalanceInit.storage; 7 | final searchHistory = storage.searchHistory ?? []; 8 | if (searchHistory.any((e) => e == room)) return; 9 | searchHistory.insert(0, room); 10 | storage.searchHistory = searchHistory; 11 | } 12 | 13 | static void setSelectedRoom(String room) { 14 | Settings.school.electricity.selectedRoom = room; 15 | addSearchHistory(room); 16 | ElectricityBalanceInit.storage.lastUpdateTime = null; 17 | } 18 | 19 | static void clearSelectedRoom() { 20 | Settings.school.electricity.selectedRoom = null; 21 | ElectricityBalanceInit.storage.lastUpdateTime = null; 22 | ElectricityBalanceInit.storage.lastBalance = null; 23 | } 24 | 25 | static Future refresh({required String selectedRoom}) async { 26 | final lastBalance = await ElectricityBalanceInit.service.getBalance(selectedRoom); 27 | if (lastBalance.roomNumber == selectedRoom) { 28 | ElectricityBalanceInit.storage.lastBalance = lastBalance; 29 | ElectricityBalanceInit.storage.lastUpdateTime = DateTime.now(); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/school/event.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/utils/async_event.dart'; 2 | 3 | final schoolEventBus = AsyncEventEmitter(); 4 | -------------------------------------------------------------------------------- /lib/school/exam_arrange/entity/exam.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'exam.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | ExamEntry _$ExamEntryFromJson(Map json) => ExamEntry( 10 | courseName: json['courseName'] as String, 11 | place: json['place'] as String, 12 | campus: json['campus'] as String, 13 | time: _$recordConvertNullable( 14 | json['time'], 15 | ($jsonValue) => ( 16 | end: DateTime.parse($jsonValue['end'] as String), 17 | start: DateTime.parse($jsonValue['start'] as String), 18 | ), 19 | ), 20 | seatNumber: (json['seatNumber'] as num?)?.toInt(), 21 | isRetake: json['isRetake'] as bool? ?? false, 22 | disqualified: json['disqualified'] as bool? ?? false, 23 | ); 24 | 25 | Map _$ExamEntryToJson(ExamEntry instance) => { 26 | 'courseName': instance.courseName, 27 | 'time': instance.time == null 28 | ? null 29 | : { 30 | 'end': instance.time!.end.toIso8601String(), 31 | 'start': instance.time!.start.toIso8601String(), 32 | }, 33 | 'place': instance.place, 34 | 'campus': instance.campus, 35 | 'seatNumber': instance.seatNumber, 36 | 'isRetake': instance.isRetake, 37 | 'disqualified': instance.disqualified, 38 | }; 39 | 40 | $Rec? _$recordConvertNullable<$Rec>( 41 | Object? value, 42 | $Rec Function(Map) convert, 43 | ) => 44 | value == null ? null : convert(value as Map); 45 | -------------------------------------------------------------------------------- /lib/school/exam_arrange/i18n.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:mimir/l10n/common.dart'; 3 | 4 | const i18n = _I18n(); 5 | 6 | class _I18n with CommonI18nMixin { 7 | const _I18n(); 8 | 9 | static const ns = "examArrange"; 10 | 11 | String get title => "$ns.title".tr(); 12 | 13 | String get check => "$ns.check".tr(); 14 | 15 | String get date => "$ns.date".tr(); 16 | 17 | String get time => "$ns.time".tr(); 18 | 19 | String get retake => "$ns.retake".tr(); 20 | 21 | String get disqualified => "$ns.disqualified".tr(); 22 | 23 | String get location => "$ns.location".tr(); 24 | 25 | String get noExamsTip => "$ns.noExamsTip".tr(); 26 | 27 | String get seatNumber => "$ns.seatNumber".tr(); 28 | 29 | String get addCalendarEvent => "$ns.addCalendarEvent".tr(); 30 | 31 | String calendarEventTitleOf(String exam) => "$ns.calendarEventTitle".tr(args: [exam]); 32 | } 33 | -------------------------------------------------------------------------------- /lib/school/exam_arrange/init.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/school/exam_arrange/storage/exam.dart'; 2 | 3 | import 'service/exam.dart'; 4 | 5 | class ExamArrangeInit { 6 | static late ExamArrangeService service; 7 | static late ExamArrangeStorage storage; 8 | 9 | static void init() { 10 | service = const ExamArrangeService(); 11 | } 12 | 13 | static void initStorage() { 14 | storage = ExamArrangeStorage(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/school/exam_arrange/service/exam.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:mimir/init.dart'; 3 | 4 | import 'package:mimir/session/ug_registration.dart'; 5 | 6 | import '../entity/exam.dart'; 7 | import 'package:mimir/school/entity/school.dart'; 8 | 9 | class ExamArrangeService { 10 | static const _examRoomUrl = 'http://jwxt.sit.edu.cn/jwglxt/kwgl/kscx_cxXsksxxIndex.html'; 11 | 12 | UgRegistrationSession get _session => Init.ugRegSession; 13 | 14 | const ExamArrangeService(); 15 | 16 | /// 获取考场信息 17 | Future> fetchExamList(SemesterInfo info) async { 18 | final response = await _session.request( 19 | _examRoomUrl, 20 | queryParameters: { 21 | 'doType': 'query', 22 | 'gnmkdm': 'N358105', 23 | }, 24 | data: () => FormData.fromMap({ 25 | // 学年名 26 | 'xnm': info.year.toString(), 27 | // 学期名 28 | 'xqm': info.semester.toUgRegFormField(), 29 | }), 30 | options: Options( 31 | method: "POST", 32 | ), 33 | ); 34 | final List itemsData = response.data['items']; 35 | final list = itemsData.map((e) => ExamEntry.parseRemoteJson(e as Map)).toList(); 36 | return list; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/school/exam_arrange/storage/exam.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | import 'package:mimir/utils/hive.dart'; 3 | import 'package:mimir/storage/hive/init.dart'; 4 | import 'package:mimir/school/entity/school.dart'; 5 | import 'package:mimir/utils/json.dart'; 6 | 7 | import '../entity/exam.dart'; 8 | 9 | class _K { 10 | // static const lastSemesterInfo = "/lastSemesterInfo"; 11 | 12 | static String examList(SemesterInfo info) => "/examList/$info"; 13 | } 14 | 15 | class ExamArrangeStorage { 16 | Box get box => HiveInit.examArrange; 17 | 18 | ExamArrangeStorage(); 19 | 20 | List? getExamList(SemesterInfo info) => decodeJsonList( 21 | box.safeGet(_K.examList(info)), 22 | (e) => ExamEntry.fromJson(e), 23 | ); 24 | 25 | void setExamList(SemesterInfo info, List? exams) => box.safePut( 26 | _K.examList(info), 27 | encodeJsonList(exams, (e) => e.toJson()), 28 | ); 29 | 30 | Stream watchExamList(SemesterInfo Function() getFilter) => 31 | box.watch().where((event) => event.key == _K.examList(getFilter())); 32 | 33 | late final $examListFamily = box.streamProviderFamily, SemesterInfo>( 34 | initial: (info) => getExamList(info), 35 | filter: (e, semester) => e.key == _K.examList(semester), 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /lib/school/exam_result/card.pg.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:mimir/design/widget/app.dart'; 5 | import 'package:rettulf/rettulf.dart'; 6 | 7 | import "i18n.dart"; 8 | 9 | class ExamResultPgAppCard extends ConsumerStatefulWidget { 10 | const ExamResultPgAppCard({super.key}); 11 | 12 | @override 13 | ConsumerState createState() => _ExamResultPgAppCardState(); 14 | } 15 | 16 | class _ExamResultPgAppCardState extends ConsumerState { 17 | @override 18 | Widget build(BuildContext context) { 19 | return AppCard( 20 | title: i18n.title.text(), 21 | leftActions: [ 22 | FilledButton.icon( 23 | onPressed: () async { 24 | await context.push("/exam/result/pg"); 25 | }, 26 | icon: const Icon(Icons.fact_check), 27 | label: i18n.check.text(), 28 | ), 29 | ], 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/school/exam_result/card.ug.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:mimir/design/widget/app.dart'; 5 | import 'package:rettulf/rettulf.dart'; 6 | 7 | import "i18n.dart"; 8 | 9 | class ExamResultUgAppCard extends ConsumerStatefulWidget { 10 | const ExamResultUgAppCard({super.key}); 11 | 12 | @override 13 | ConsumerState createState() => _ExamResultUgAppCardState(); 14 | } 15 | 16 | class _ExamResultUgAppCardState extends ConsumerState { 17 | @override 18 | Widget build(BuildContext context) { 19 | return AppCard( 20 | title: i18n.title.text(), 21 | leftActions: [ 22 | FilledButton.icon( 23 | onPressed: () async { 24 | await context.push("/exam/result/ug"); 25 | }, 26 | icon: const Icon(Icons.fact_check), 27 | label: i18n.check.text(), 28 | ), 29 | ], 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/school/exam_result/entity/gpa.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import 'package:mimir/school/entity/school.dart'; 3 | import 'package:mimir/school/exam_result/entity/result.ug.dart'; 4 | 5 | class ExamResultGpaItem { 6 | // for multi-selection 7 | final int index; 8 | 9 | /// the first attempt of an exam. 10 | final ExamResultUg initial; 11 | final List resit; 12 | final List retake; 13 | 14 | const ExamResultGpaItem({ 15 | required this.index, 16 | required this.initial, 17 | required this.resit, 18 | required this.retake, 19 | }); 20 | 21 | /// Using the [initial.year] 22 | SchoolYear get year => initial.year; 23 | 24 | /// Using the [initial.semester] 25 | Semester get semester => initial.semester; 26 | 27 | /// Using the [initial.semesterInfo] 28 | SemesterInfo get semesterInfo => initial.semesterInfo; 29 | 30 | CourseCat get courseCat => initial.courseCat; 31 | 32 | /// Even if you retake the course, there will be no change. 33 | String get courseCode => initial.courseCode; 34 | 35 | String get courseName => initial.courseName; 36 | 37 | double get credit => initial.credit; 38 | 39 | double? get maxScore { 40 | return [ 41 | ...resit.map((e) => e.score), 42 | ...retake.map((e) => e.score), 43 | initial.score, 44 | ].nonNulls.maxOrNull; 45 | } 46 | 47 | bool get passed { 48 | final maxScore = this.maxScore; 49 | if (maxScore == null) return false; 50 | return maxScore >= 60.0; 51 | } 52 | 53 | @override 54 | bool operator ==(Object other) { 55 | if (identical(this, other)) return true; 56 | return other is ExamResultGpaItem && 57 | runtimeType == other.runtimeType && 58 | initial == other.initial && 59 | resit.equals(other.resit) && 60 | retake.equals(other.retake); 61 | } 62 | 63 | @override 64 | int get hashCode => Object.hash( 65 | initial, 66 | Object.hashAll(resit), 67 | Object.hashAll(retake), 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /lib/school/exam_result/entity/result.pg.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'result.pg.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class ExamResultPgAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 23; 12 | 13 | @override 14 | ExamResultPg read(BinaryReader reader) { 15 | final numOfFields = reader.readByte(); 16 | final fields = { 17 | for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), 18 | }; 19 | return ExamResultPg( 20 | courseType: fields[0] as String, 21 | courseCode: fields[1] as String, 22 | courseName: fields[2] as String, 23 | credit: fields[3] as int, 24 | teacher: fields[4] as String, 25 | score: fields[5] as double, 26 | passed: fields[6] as bool, 27 | examType: fields[7] as String, 28 | form: fields[8] as String, 29 | time: fields[9] as DateTime?, 30 | notes: fields[10] as String, 31 | ); 32 | } 33 | 34 | @override 35 | void write(BinaryWriter writer, ExamResultPg obj) { 36 | writer 37 | ..writeByte(11) 38 | ..writeByte(0) 39 | ..write(obj.courseType) 40 | ..writeByte(1) 41 | ..write(obj.courseCode) 42 | ..writeByte(2) 43 | ..write(obj.courseName) 44 | ..writeByte(3) 45 | ..write(obj.credit) 46 | ..writeByte(4) 47 | ..write(obj.teacher) 48 | ..writeByte(5) 49 | ..write(obj.score) 50 | ..writeByte(6) 51 | ..write(obj.passed) 52 | ..writeByte(7) 53 | ..write(obj.examType) 54 | ..writeByte(8) 55 | ..write(obj.form) 56 | ..writeByte(9) 57 | ..write(obj.time) 58 | ..writeByte(10) 59 | ..write(obj.notes); 60 | } 61 | 62 | @override 63 | int get hashCode => typeId.hashCode; 64 | 65 | @override 66 | bool operator ==(Object other) => 67 | identical(this, other) || 68 | other is ExamResultPgAdapter && runtimeType == other.runtimeType && typeId == other.typeId; 69 | } 70 | -------------------------------------------------------------------------------- /lib/school/exam_result/i18n.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:mimir/l10n/common.dart'; 3 | import 'package:mimir/school/i18n.dart'; 4 | 5 | const i18n = _I18n(); 6 | 7 | class _I18n with CommonI18nMixin { 8 | const _I18n(); 9 | 10 | static const ns = "examResult"; 11 | final gpa = const _Gpa(); 12 | final course = const CourseI18n(); 13 | 14 | String get title => "$ns.title".tr(); 15 | 16 | String get check => "$ns.check".tr(); 17 | 18 | String get score => "$ns.score".tr(); 19 | 20 | String get maxScore => "$ns.maxScore".tr(); 21 | 22 | String get publishTime => "$ns.publishTime".tr(); 23 | 24 | String get teacherEval => "$ns.teacherEval".tr(); 25 | 26 | String get teacherEvalTitle => "$ns.teacherEvalTitle".tr(); 27 | 28 | String get noResultsTip => "$ns.noResultsTip".tr(); 29 | 30 | String get examType => "$ns.examType.title".tr(); 31 | 32 | String get courseNotEval => "$ns.courseNotEval".tr(); 33 | 34 | String get examRequireEvalTip => "$ns.examRequireEvalTip".tr(); 35 | } 36 | 37 | class _Gpa { 38 | const _Gpa(); 39 | 40 | static const ns = "${_I18n.ns}.gpa"; 41 | 42 | String get title => "$ns.title".tr(); 43 | 44 | String lessonSelected(int count) => "$ns.lessonSelected".tr(args: [ 45 | count.toString(), 46 | ]); 47 | 48 | String credit(double point) => "$ns.credit".tr(args: [ 49 | point.toString(), 50 | ]); 51 | 52 | String gpaResult(double point) => "$ns.gpaResult".tr(args: [ 53 | point.toStringAsPrecision(2), 54 | ]); 55 | 56 | String get selectAll => "$ns.selectAll".tr(); 57 | 58 | String get invert => "$ns.invert".tr(); 59 | 60 | String get exceptGenEd => "$ns.exceptGenEd".tr(); 61 | 62 | String get exceptFailed => "$ns.exceptFailed".tr(); 63 | } 64 | -------------------------------------------------------------------------------- /lib/school/exam_result/init.dart: -------------------------------------------------------------------------------- 1 | import 'service/result.pg.dart'; 2 | import 'service/result.ug.dart'; 3 | import 'storage/result.pg.dart'; 4 | import 'storage/result.ug.dart'; 5 | 6 | class ExamResultInit { 7 | static late ExamResultUgService ugService; 8 | static late ExamResultPgService pgService; 9 | static late ExamResultUgStorage ugStorage; 10 | static late ExamResultPgStorage pgStorage; 11 | 12 | static void init() { 13 | ugService = const ExamResultUgService(); 14 | pgService = const ExamResultPgService(); 15 | } 16 | 17 | static void initStorage() { 18 | ugStorage = ExamResultUgStorage(); 19 | pgStorage = ExamResultPgStorage(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/school/exam_result/storage/result.pg.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/utils/hive.dart'; 2 | import 'package:hive_flutter/hive_flutter.dart'; 3 | import 'package:mimir/school/exam_result/entity/result.pg.dart'; 4 | import 'package:mimir/storage/hive/init.dart'; 5 | 6 | class _K { 7 | static const ns = "/pg"; 8 | 9 | static const resultList = "$ns/resultList"; 10 | } 11 | 12 | class ExamResultPgStorage { 13 | Box get box => HiveInit.examResult; 14 | 15 | ExamResultPgStorage(); 16 | 17 | List? getResultList() => box.safeGet(_K.resultList)?.cast(); 18 | 19 | Future setResultList(List? newV) => box.safePut(_K.resultList, newV); 20 | 21 | late final $resultList = box.provider>(_K.resultList, get: getResultList); 22 | } 23 | -------------------------------------------------------------------------------- /lib/school/exam_result/storage/result.ug.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:hive_flutter/hive_flutter.dart'; 3 | import 'package:mimir/utils/hive.dart'; 4 | import 'package:mimir/storage/hive/init.dart'; 5 | import 'package:mimir/school/entity/school.dart'; 6 | 7 | import '../entity/result.ug.dart'; 8 | 9 | class _K { 10 | static const ns = "/ug"; 11 | static const lastSemesterInfo = "$ns/lastSemesterInfo"; 12 | 13 | static String resultList(SemesterInfo info) => "$ns/resultList/$info"; 14 | } 15 | 16 | class ExamResultUgStorage { 17 | Box get box => HiveInit.examResult; 18 | 19 | ExamResultUgStorage(); 20 | 21 | List? getResultList(SemesterInfo info) => box.safeGet(_K.resultList(info))?.cast(); 22 | 23 | Future setResultList(SemesterInfo info, List? results) => 24 | box.safePut(_K.resultList(info), results); 25 | 26 | ValueListenable listenResultList(SemesterInfo info) => box.listenable(keys: [_K.resultList(info)]); 27 | 28 | Stream watchResultList(SemesterInfo Function() getFilter) => 29 | box.watch().where((event) => event.key == _K.resultList(getFilter())); 30 | 31 | late final $resultListFamily = box.streamProviderFamily, SemesterInfo>( 32 | initial: (info) => getResultList(info), 33 | filter: (e, semester) => e.key == _K.resultList(semester), 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /lib/school/exam_result/utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import 'package:mimir/school/entity/school.dart'; 3 | 4 | import 'entity/gpa.dart'; 5 | import 'entity/result.ug.dart'; 6 | 7 | ({double gpa, double credit}) calcGPA(Iterable<({double score, double credit})> resultList) { 8 | double totalCredits = 0.0; 9 | double sum = 0.0; 10 | 11 | for (final s in resultList) { 12 | final score = s.score; 13 | assert(score >= 0, "Exam score should be >= 0"); 14 | totalCredits += s.credit; 15 | sum += s.credit * score; 16 | } 17 | final res = sum / totalCredits / 10.0 - 5.0; 18 | return (gpa: res.isNaN ? 0 : res, credit: totalCredits); 19 | } 20 | 21 | List filterGpaAvailableResult(List list) { 22 | return list.where((result) => result.score != null && !result.isPreparatory).toList(); 23 | } 24 | 25 | List<({SemesterInfo semester, List results})> groupExamResultList(List list) { 26 | final semester2Result = list.groupListsBy((result) => result.semesterInfo); 27 | final groups = semester2Result.entries.map((entry) => (semester: entry.key, results: entry.value)).toList(); 28 | groups.sortBy((group) => group.semester); 29 | return groups; 30 | } 31 | 32 | List extractExamResultGpaItems(List list) { 33 | final groupByExamType = list.groupListsBy((result) => result.examType); 34 | final normal = groupByExamType[UgExamType.normal] ?? []; 35 | final resit = groupByExamType[UgExamType.resit] ?? []; 36 | final retake = groupByExamType[UgExamType.retake] ?? []; 37 | 38 | final res = []; 39 | var index = 0; 40 | for (final exam in normal) { 41 | final relatedResit = resit.where((e) => e.courseCode == exam.courseCode).toList(); 42 | final relatedRetake = retake.where((e) => e.courseCode == exam.courseCode).toList(); 43 | res.add(ExamResultGpaItem( 44 | index: index, 45 | initial: exam, 46 | resit: relatedResit, 47 | retake: relatedRetake, 48 | )); 49 | index++; 50 | } 51 | return res; 52 | } 53 | 54 | List<({SemesterInfo semester, List items})> groupExamResultGpaItems(List list) { 55 | final semester2Result = list.groupListsBy((result) => result.semesterInfo); 56 | final groups = semester2Result.entries.map((entry) => (semester: entry.key, items: entry.value)).toList(); 57 | groups.sortBy((group) => group.semester); 58 | return groups; 59 | } 60 | -------------------------------------------------------------------------------- /lib/school/exam_result/widget/pg.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mimir/design/adaptive/foundation.dart'; 3 | import 'package:mimir/design/widget/card.dart'; 4 | import 'package:mimir/school/widget/course.dart'; 5 | import 'package:rettulf/rettulf.dart'; 6 | import 'package:text_scroll/text_scroll.dart'; 7 | 8 | import '../entity/result.pg.dart'; 9 | import '../i18n.dart'; 10 | 11 | class ExamResultPgCard extends StatelessWidget { 12 | final bool elevated; 13 | final ExamResultPg result; 14 | 15 | const ExamResultPgCard( 16 | this.result, { 17 | super.key, 18 | required this.elevated, 19 | }); 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | final textTheme = context.textTheme; 24 | return ListTile( 25 | isThreeLine: true, 26 | leading: CourseIcon(courseName: result.courseName), 27 | titleTextStyle: textTheme.titleMedium, 28 | title: Text(result.courseName), 29 | subtitleTextStyle: textTheme.bodyMedium, 30 | subtitle: [ 31 | '${result.courseType} ${result.teacher}'.text(), 32 | '${result.examType} | ${i18n.course.credit}: ${result.credit}'.text(), 33 | ].column(caa: CrossAxisAlignment.start), 34 | leadingAndTrailingTextStyle: textTheme.labelSmall?.copyWith( 35 | fontSize: textTheme.bodyLarge?.fontSize, 36 | color: result.passed ? null : context.$red$, 37 | ), 38 | trailing: result.score.toString().text(), 39 | ).inAnyCard(clip: Clip.hardEdge, type: elevated ? CardVariant.elevated : CardVariant.filled); 40 | } 41 | } 42 | 43 | class ExamResultPgCarouselCard extends StatelessWidget { 44 | final bool elevated; 45 | final ExamResultPg result; 46 | 47 | const ExamResultPgCarouselCard( 48 | this.result, { 49 | super.key, 50 | required this.elevated, 51 | }); 52 | 53 | @override 54 | Widget build(BuildContext context) { 55 | final textTheme = context.textTheme; 56 | return Card( 57 | child: [ 58 | CourseIcon(courseName: result.courseName), 59 | TextScroll(result.courseName), 60 | result.teacher.text(), 61 | result.score.toString().text( 62 | style: textTheme.labelSmall?.copyWith( 63 | fontSize: textTheme.bodyLarge?.fontSize, 64 | color: result.passed ? null : context.$red$, 65 | ), 66 | ), 67 | ].column(maa: MainAxisAlignment.center), 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/school/exam_result/widget/ug.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mimir/design/adaptive/foundation.dart'; 3 | import 'package:mimir/school/exam_result/page/details.ug.dart'; 4 | import 'package:mimir/school/widget/course.dart'; 5 | import 'package:rettulf/rettulf.dart'; 6 | 7 | import '../i18n.dart'; 8 | import '../entity/result.ug.dart'; 9 | 10 | class ExamResultUgTile extends StatelessWidget { 11 | final ExamResultUg result; 12 | 13 | const ExamResultUgTile( 14 | this.result, { 15 | super.key, 16 | }); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | final textTheme = context.textTheme; 21 | final score = result.score; 22 | return ListTile( 23 | isThreeLine: true, 24 | leading: CourseIcon(courseName: result.courseName), 25 | titleTextStyle: textTheme.titleMedium, 26 | title: Text(result.courseName), 27 | subtitleTextStyle: textTheme.bodyMedium, 28 | subtitle: [ 29 | result.examType.l10n().text(), 30 | if (result.teachers.isNotEmpty) result.teachers.join(", ").text(), 31 | ].column(caa: CrossAxisAlignment.start, mas: MainAxisSize.min), 32 | leadingAndTrailingTextStyle: textTheme.labelSmall?.copyWith( 33 | fontSize: textTheme.bodyLarge?.fontSize, 34 | color: result.passed ? null : context.$red$, 35 | ), 36 | trailing: score != null ? score.toString().text() : i18n.courseNotEval.text(), 37 | onTap: () async { 38 | context.showSheet((ctx) => ExamResultUgDetailsPage(result)); 39 | }, 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/school/exam_result/x.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import 'package:mimir/school/entity/school.dart'; 3 | 4 | import 'entity/result.ug.dart'; 5 | import 'init.dart'; 6 | 7 | class XExamResult { 8 | static Future<({Map> semester2Results, List all})> 9 | fetchAndCacheExamResultUgEachSemester({ 10 | void Function(double progress)? onProgress, 11 | }) async { 12 | final all = await ExamResultInit.ugService.fetchResultList( 13 | SemesterInfo.all, 14 | onProgress: onProgress, 15 | ); 16 | final semester2Results = all.groupListsBy((result) => result.semesterInfo); 17 | final storage = ExamResultInit.ugStorage; 18 | await storage.setResultList(SemesterInfo.all, all); 19 | for (final MapEntry(key: semester, value: list) in semester2Results.entries) { 20 | await storage.setResultList(semester, list); 21 | } 22 | return (semester2Results: semester2Results, all: all); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/school/expense_records/entity/remote.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'remote.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | DataPackRaw _$DataPackRawFromJson(Map json) => DataPackRaw( 10 | code: (json['retcode'] as num).toInt(), 11 | count: (json['retcount'] as num).toInt(), 12 | transactions: 13 | (json['retdata'] as List).map((e) => TransactionRaw.fromJson(e as Map)).toList(), 14 | message: json['retmsg'] as String, 15 | ); 16 | 17 | TransactionRaw _$TransactionRawFromJson(Map json) => TransactionRaw( 18 | date: json['transdate'] as String, 19 | time: json['transtime'] as String, 20 | customerId: (json['custid'] as num).toInt(), 21 | flag: (json['transflag'] as num).toInt(), 22 | balanceBeforeTransaction: (json['cardbefbal'] as num).toDouble(), 23 | balanceAfterTransaction: (json['cardaftbal'] as num).toDouble(), 24 | amount: (json['amount'] as num).toDouble(), 25 | deviceName: json['devicename'] as String?, 26 | name: json['transname'] as String, 27 | ); 28 | -------------------------------------------------------------------------------- /lib/school/expense_records/init.dart: -------------------------------------------------------------------------------- 1 | import 'service/fetch.dart'; 2 | import 'storage/local.dart'; 3 | 4 | class ExpenseRecordsInit { 5 | static late ExpenseService service; 6 | static late ExpenseStorage storage; 7 | 8 | static void init() { 9 | service = const ExpenseService(); 10 | } 11 | 12 | static void initStorage() { 13 | storage = ExpenseStorage(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/school/expense_records/service/fetch.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | import 'dart:convert'; 3 | 4 | import 'package:crypto/crypto.dart'; 5 | import 'package:dio/dio.dart'; 6 | import 'package:mimir/init.dart'; 7 | 8 | import 'package:mimir/session/sso.dart'; 9 | 10 | import '../entity/local.dart'; 11 | import '../entity/remote.dart'; 12 | import '../utils.dart'; 13 | 14 | class ExpenseService { 15 | String _al2(int v) => v < 10 ? "0$v" : "$v"; 16 | 17 | String _format(DateTime d) => 18 | "${d.year}${_al2(d.month)}${_al2(d.day)}${_al2(d.hour)}${_al2(d.minute)}${_al2(d.second)}"; 19 | 20 | static const String magicNumber = "adc4ac6822fd462780f878b86cb94688"; 21 | static const urlPath = "https://xgfy.sit.edu.cn/yktapi/services/querytransservice/querytrans"; 22 | 23 | SsoSession get _session => Init.ssoSession; 24 | 25 | const ExpenseService(); 26 | 27 | Future> fetch({ 28 | required String studentID, 29 | required DateTime from, 30 | required DateTime to, 31 | }) async { 32 | final curTs = _format(DateTime.now()); 33 | final fromTs = _format(from); 34 | final toTs = _format(to); 35 | final auth = _composeAuth(studentID, fromTs, toTs, curTs); 36 | 37 | final res = await _session.request( 38 | urlPath, 39 | options: Options( 40 | contentType: 'text/plain', 41 | method: "POST", 42 | ), 43 | queryParameters: { 44 | 'timestamp': curTs, 45 | 'starttime': fromTs, 46 | 'endtime': toTs, 47 | 'sign': auth, 48 | 'sign_method': 'HMAC', 49 | 'stuempno': studentID, 50 | }, 51 | ); 52 | final raw = _parseDataPack(res.data); 53 | final list = raw.transactions.map(parseFull).toList(); 54 | return list; 55 | } 56 | 57 | DataPackRaw _parseDataPack(dynamic data) { 58 | final res = HashMap.of(data); 59 | final retdataRaw = res["retdata"]; 60 | final retdata = json.decode(retdataRaw); 61 | res["retdata"] = retdata; 62 | return DataPackRaw.fromJson(res); 63 | } 64 | 65 | String _composeAuth(String studentId, String from, String to, String cur) { 66 | final full = studentId + from + to + cur; 67 | final msg = utf8.encode(full); 68 | final key = utf8.encode(magicNumber); 69 | final hmac = Hmac(sha1, key); 70 | return hmac.convert(msg).toString(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/school/expense_records/widget/balance.dart: -------------------------------------------------------------------------------- 1 | import 'package:auto_size_text/auto_size_text.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:mimir/design/animation/number.dart'; 4 | import 'package:mimir/utils/format.dart'; 5 | import 'package:rettulf/rettulf.dart'; 6 | import "../i18n.dart"; 7 | 8 | class BalanceCard extends StatelessWidget { 9 | final double balance; 10 | final bool removeTrailingZeros; 11 | final double? warningBalance; 12 | final Color warningColor; 13 | 14 | const BalanceCard({ 15 | super.key, 16 | required this.balance, 17 | this.warningBalance = 10.0, 18 | this.warningColor = Colors.redAccent, 19 | this.removeTrailingZeros = false, 20 | }); 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | final textTheme = context.textTheme; 25 | final warningBalance = this.warningBalance; 26 | final balanceColor = warningBalance == null || warningBalance < balance ? null : warningColor; 27 | return [ 28 | AutoSizeText( 29 | i18n.view.balance, 30 | style: textTheme.titleLarge, 31 | maxLines: 1, 32 | ), 33 | AnimatedNumber( 34 | value: balance, 35 | builder: (context, balance) { 36 | return AutoSizeText( 37 | removeTrailingZeros ? formatWithoutTrailingZeros(balance) : balance.toStringAsFixed(2), 38 | style: textTheme.displayMedium?.copyWith(color: balanceColor), 39 | maxLines: 1, 40 | ); 41 | }), 42 | AutoSizeText( 43 | i18n.view.rmb, 44 | style: textTheme.titleMedium, 45 | maxLines: 1, 46 | ), 47 | ] 48 | .column( 49 | caa: CrossAxisAlignment.start, 50 | maa: MainAxisAlignment.spaceEvenly, 51 | ) 52 | .padAll(10) 53 | .inCard(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/school/expense_records/widget/chart/header.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:rettulf/rettulf.dart'; 3 | 4 | class ExpenseChartHeader extends StatelessWidget { 5 | final String upper; 6 | final String content; 7 | final String? lower; 8 | 9 | const ExpenseChartHeader({ 10 | super.key, 11 | required this.upper, 12 | required this.content, 13 | this.lower, 14 | }); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return [ 19 | ExpenseChartHeaderLabel(upper), 20 | content.text(style: context.textTheme.titleLarge), 21 | if (lower != null) ExpenseChartHeaderLabel(lower!), 22 | ].column(caa: CrossAxisAlignment.start); 23 | } 24 | } 25 | 26 | class ExpenseChartHeaderLabel extends StatelessWidget { 27 | final String text; 28 | 29 | const ExpenseChartHeaderLabel( 30 | this.text, { 31 | super.key, 32 | }); 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | final style = context.textTheme.titleMedium?.copyWith(color: context.theme.disabledColor); 37 | return text.text(style: style); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/school/expense_records/widget/group.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mimir/design/widget/grouped.dart'; 3 | import 'package:mimir/l10n/extension.dart'; 4 | import 'package:rettulf/rettulf.dart'; 5 | 6 | import '../entity/local.dart'; 7 | import '../utils.dart'; 8 | import '../i18n.dart'; 9 | import 'transaction.dart'; 10 | 11 | class TransactionGroupSection extends StatelessWidget { 12 | final bool initialExpanded; 13 | final YearMonth time; 14 | final List records; 15 | 16 | const TransactionGroupSection({ 17 | required this.time, 18 | required this.records, 19 | this.initialExpanded = true, 20 | super.key, 21 | }); 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | final (:income, :outcome) = accumulateTransactionIncomeOutcome(records); 26 | return GroupedSection( 27 | headerBuilder: (context, expanded, toggleExpand, defaultTrailing) { 28 | return ListTile( 29 | title: context.formatYmText((time.toDateTime())).text(), 30 | titleTextStyle: context.textTheme.titleMedium, 31 | subtitle: "${i18n.income(income.toStringAsFixed(2))}\n${i18n.outcome(outcome.toStringAsFixed(2))}" 32 | .text(maxLines: 2), 33 | onTap: toggleExpand, 34 | trailing: defaultTrailing, 35 | ); 36 | }, 37 | initialExpanded: initialExpanded, 38 | itemCount: records.length, 39 | itemBuilder: (ctx, i) { 40 | final record = records[i]; 41 | return TransactionTile(record); 42 | }, 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/school/expense_records/widget/selector.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:rettulf/rettulf.dart'; 3 | 4 | class YearMonthSelector extends StatefulWidget { 5 | final List years; 6 | final List months; 7 | final int initialYear; 8 | final int initialMonth; 9 | final bool enableAllYears; 10 | final bool enableAllMonths; 11 | 12 | const YearMonthSelector({ 13 | super.key, 14 | required this.years, 15 | required this.months, 16 | this.enableAllYears = false, 17 | this.enableAllMonths = false, 18 | required this.initialYear, 19 | required this.initialMonth, 20 | }); 21 | 22 | @override 23 | State createState() => _YearMonthSelectorState(); 24 | } 25 | 26 | class _YearMonthSelectorState extends State { 27 | late int selectedYear; 28 | late int selectedMonth; 29 | 30 | @override 31 | Widget build(BuildContext context) { 32 | return Row( 33 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 34 | children: [ 35 | buildYearSelector(), 36 | buildMonthSelector(), 37 | ], 38 | ); 39 | } 40 | 41 | Widget buildYearSelector() { 42 | return DropdownMenu( 43 | label: "Year".text(), 44 | initialSelection: widget.initialYear, 45 | onSelected: (int? selected) { 46 | if (selected != null && selected != selectedYear) { 47 | setState(() => selectedYear = selected); 48 | } 49 | }, 50 | dropdownMenuEntries: widget.years 51 | .map((year) => DropdownMenuEntry( 52 | value: year, 53 | label: "$year–${year + 1}", 54 | )) 55 | .toList(), 56 | ); 57 | } 58 | 59 | Widget buildMonthSelector() { 60 | return DropdownMenu( 61 | label: "Month".text(), 62 | initialSelection: widget.initialMonth, 63 | onSelected: (int? selected) { 64 | if (selected != null && selected != selectedYear) { 65 | setState(() => selectedYear = selected); 66 | } 67 | }, 68 | dropdownMenuEntries: widget.months 69 | .map((month) => DropdownMenuEntry( 70 | value: month, 71 | label: month.toString(), 72 | )) 73 | .toList(), 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/school/expense_records/x.dart: -------------------------------------------------------------------------------- 1 | import 'entity/local.dart'; 2 | import 'init.dart'; 3 | 4 | class XExpense { 5 | static Future fetchAndSaveTransactionUntilNow({ 6 | required String oaAccount, 7 | }) async { 8 | final storage = ExpenseRecordsInit.storage; 9 | final now = DateTime.now(); 10 | final start = now.copyWith(year: now.year - 6); 11 | final newlyFetched = await ExpenseRecordsInit.service.fetch( 12 | studentID: oaAccount, 13 | from: start, 14 | to: now, 15 | ); 16 | storage.lastUpdateTime = DateTime.now(); 17 | final oldTsList = storage.transactionTsList ?? const []; 18 | final newTsList = {...newlyFetched.map((e) => e.timestamp), ...oldTsList}.toList(); 19 | // the latest goes first 20 | newTsList.sort((a, b) => -a.compareTo(b)); 21 | for (final transaction in newlyFetched) { 22 | storage.setTransactionByTs(transaction.timestamp, transaction); 23 | } 24 | storage.transactionTsList = newTsList; 25 | final latest = newlyFetched.firstOrNull; 26 | if (latest != null) { 27 | final latestValidBalance = _findLatestValidBalanceTransaction(newlyFetched, newTsList); 28 | // check if the transaction is kept for topping up 29 | if (latestValidBalance != null) { 30 | storage.lastTransaction = latest.copyWith( 31 | balanceBefore: latestValidBalance.balanceBefore, 32 | balanceAfter: latestValidBalance.balanceAfter, 33 | ); 34 | } else { 35 | storage.lastTransaction = latest; 36 | } 37 | } 38 | } 39 | 40 | /// [newlyFetched] is descending by time. 41 | static Transaction? _findLatestValidBalanceTransaction(List newlyFetched, List allTsList) { 42 | for (final transaction in newlyFetched) { 43 | if (transaction.type != TransactionType.topUp && 44 | transaction.balanceBefore != 0 && 45 | transaction.balanceAfter != 0) { 46 | return transaction; 47 | } 48 | } 49 | for (final ts in allTsList) { 50 | final transaction = ExpenseRecordsInit.storage.getTransactionByTs(ts); 51 | if (transaction == null) continue; 52 | if (transaction.type != TransactionType.topUp && 53 | transaction.balanceBefore != 0 && 54 | transaction.balanceAfter != 0) { 55 | return transaction; 56 | } 57 | } 58 | return null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/school/init.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/design/adaptive/editor.dart'; 2 | 3 | import 'entity/school.dart'; 4 | 5 | class SchoolInit { 6 | static void init() { 7 | EditorEx.registerEnumEditor(Semester.values); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/school/oa_announce/entity/page.dart: -------------------------------------------------------------------------------- 1 | import 'announce.dart'; 2 | 3 | /// 获取到的通知页 4 | class OaAnnounceListPayload { 5 | final int currentPage; 6 | final int totalPage; 7 | final List items; 8 | 9 | const OaAnnounceListPayload({ 10 | required this.currentPage, 11 | required this.totalPage, 12 | required this.items, 13 | }); 14 | 15 | @override 16 | String toString() { 17 | return { 18 | "currentPage": currentPage, 19 | "totalPage": totalPage, 20 | "items": items, 21 | }.toString(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/school/oa_announce/i18n.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:mimir/l10n/common.dart'; 3 | 4 | const i18n = _I18n(); 5 | 6 | class _I18n with CommonI18nMixin { 7 | const _I18n(); 8 | 9 | static const ns = "oaAnnounce"; 10 | final info = const _Info(); 11 | 12 | String get title => "$ns.title".tr(); 13 | 14 | String get noOaAnnouncementsTip => "$ns.noOaAnnouncementsTip".tr(); 15 | 16 | String get downloadCompleted => "$ns.downloadCompleted".tr(); 17 | 18 | String get downloadFailed => "$ns.downloadFailed".tr(); 19 | 20 | String get downloading => "$ns.downloading".tr(); 21 | } 22 | 23 | class _Info { 24 | const _Info(); 25 | 26 | static const ns = "${_I18n.ns}.info"; 27 | 28 | String attachmentHeader(int count) => "$ns.attachmentHeader".plural(count); 29 | 30 | String get title => "$ns.title".tr(); 31 | 32 | String get publishTime => "$ns.publishTime".tr(); 33 | 34 | String get department => "$ns.department".tr(); 35 | 36 | String get author => "$ns.author".tr(); 37 | 38 | String get tags => "$ns.tags".tr(); 39 | } 40 | -------------------------------------------------------------------------------- /lib/school/oa_announce/index.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:mimir/design/widget/app.dart'; 4 | import 'package:rettulf/rettulf.dart'; 5 | 6 | import "i18n.dart"; 7 | 8 | class OaAnnounceAppCard extends StatefulWidget { 9 | const OaAnnounceAppCard({super.key}); 10 | 11 | @override 12 | State createState() => _OaAnnounceAppCardState(); 13 | } 14 | 15 | class _OaAnnounceAppCardState extends State { 16 | @override 17 | Widget build(BuildContext context) { 18 | return AppCard( 19 | title: i18n.title.text(), 20 | leftActions: [ 21 | FilledButton.icon( 22 | onPressed: () { 23 | context.push("/oa/announcement"); 24 | }, 25 | icon: const Icon(Icons.newspaper), 26 | label: i18n.seeAll.text(), 27 | ), 28 | ], 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/school/oa_announce/init.dart: -------------------------------------------------------------------------------- 1 | import 'storage/announce.dart'; 2 | 3 | import 'service/announce.dart'; 4 | 5 | class OaAnnounceInit { 6 | static late OaAnnounceService service; 7 | static late OaAnnounceStorage storage; 8 | 9 | static void init() { 10 | service = const OaAnnounceService(); 11 | } 12 | 13 | static void initStorage() { 14 | storage = const OaAnnounceStorage(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/school/oa_announce/storage/announce.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/utils/hive.dart'; 2 | import 'package:hive/hive.dart'; 3 | import 'package:mimir/storage/hive/init.dart'; 4 | 5 | import '../entity/announce.dart'; 6 | 7 | class _K { 8 | static String announce(String uuid) => '/announce/$uuid'; 9 | 10 | static String announceDetails(String uuid) => '/announceDetails/$uuid'; 11 | 12 | static String announceIdList(OaAnnounceCat type) => '/announceIdList/$type'; 13 | } 14 | 15 | class OaAnnounceStorage { 16 | Box get box => HiveInit.oaAnnounce; 17 | 18 | const OaAnnounceStorage(); 19 | 20 | List? getAnnounceIdList(OaAnnounceCat type) => box.safeGet>(_K.announceIdList(type)); 21 | 22 | Future setAnnounceIdList(OaAnnounceCat type, List? announceIdList) => 23 | box.safePut>(_K.announceIdList(type), announceIdList); 24 | 25 | OaAnnounceRecord? getAnnounce(String uuid) => box.safeGet(_K.announce(uuid)); 26 | 27 | Future setAnnounce(String uuid, OaAnnounceRecord? announce) => 28 | box.safePut(_K.announce(uuid), announce); 29 | 30 | OaAnnounceDetails? getAnnounceDetails(String uuid) => box.safeGet(_K.announceDetails(uuid)); 31 | 32 | Future setAnnounceDetails(String uuid, OaAnnounceDetails? details) => 33 | box.safePut(_K.announceDetails(uuid), details); 34 | 35 | List? getAnnouncements(OaAnnounceCat type) { 36 | final idList = getAnnounceIdList(type); 37 | if (idList == null) return null; 38 | final res = []; 39 | for (final id in idList) { 40 | final announce = getAnnounce(id); 41 | if (announce != null) { 42 | res.add(announce); 43 | } 44 | } 45 | return res; 46 | } 47 | 48 | Future? setAnnouncements(OaAnnounceCat type, List? announcements) async { 49 | if (announcements == null) { 50 | await setAnnouncements(type, null); 51 | } else { 52 | await setAnnounceIdList(type, announcements.map((e) => e.uuid).toList(growable: false)); 53 | for (final announce in announcements) { 54 | await setAnnounce(announce.uuid, announce); 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/school/oa_announce/widget/tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:mimir/design/widget/tags.dart'; 4 | import 'package:mimir/l10n/extension.dart'; 5 | import 'package:mimir/school/utils.dart'; 6 | import 'package:rettulf/rettulf.dart'; 7 | 8 | import '../entity/announce.dart'; 9 | 10 | class OaAnnounceTile extends StatelessWidget { 11 | final OaAnnounceRecord record; 12 | 13 | const OaAnnounceTile( 14 | this.record, { 15 | super.key, 16 | }); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | final textTheme = context.textTheme; 21 | final (:title, :tags) = separateTagsFromTitle(record.title); 22 | 23 | return ListTile( 24 | isThreeLine: true, 25 | titleTextStyle: textTheme.titleMedium, 26 | title: title.text(), 27 | subtitleTextStyle: textTheme.bodySmall, 28 | subtitle: TagsGroup(record.departments + tags), 29 | trailing: context.formatYmdNum(record.dateTime).text(style: textTheme.bodySmall), 30 | onTap: () { 31 | context.push("/oa/announcement/details", extra: record); 32 | }, 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/school/settings.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:mimir/utils/hive.dart'; 3 | import 'package:hive_flutter/hive_flutter.dart'; 4 | 5 | const _kElectricityAutoRefresh = true; 6 | const _kExpenseRecordsAutoRefresh = true; 7 | 8 | const _kClass2ndAutoRefresh = true; 9 | 10 | class SchoolSettings { 11 | final Box box; 12 | 13 | SchoolSettings(this.box); 14 | 15 | late final electricity = _Electricity(box); 16 | late final expense = _ExpenseRecords(box); 17 | late final class2nd = _Class2nd(box); 18 | 19 | static const ns = "/school"; 20 | } 21 | 22 | class _Class2ndK { 23 | static const ns = "${SchoolSettings.ns}/class2nd"; 24 | static const autoRefresh = "$ns/autoRefresh"; 25 | } 26 | 27 | class _Class2nd { 28 | final Box box; 29 | 30 | const _Class2nd(this.box); 31 | 32 | bool get autoRefresh => box.safeGet(_Class2ndK.autoRefresh) ?? _kClass2ndAutoRefresh; 33 | 34 | set autoRefresh(bool newV) => box.safePut(_Class2ndK.autoRefresh, newV); 35 | } 36 | 37 | class _ElectricityK { 38 | static const ns = "${SchoolSettings.ns}/electricity"; 39 | static const autoRefresh = "$ns/autoRefresh"; 40 | static const selectedRoom = "$ns/selectedRoom"; 41 | } 42 | 43 | class _Electricity { 44 | final Box box; 45 | 46 | _Electricity(this.box); 47 | 48 | bool get autoRefresh => box.safeGet(_ElectricityK.autoRefresh) ?? _kElectricityAutoRefresh; 49 | 50 | set autoRefresh(bool foo) => box.safePut(_ElectricityK.autoRefresh, foo); 51 | 52 | String? get selectedRoom => box.safeGet(_ElectricityK.selectedRoom); 53 | 54 | late final $selectedRoom = box.provider(_ElectricityK.selectedRoom); 55 | 56 | set selectedRoom(String? newV) => box.safePut(_ElectricityK.selectedRoom, newV); 57 | 58 | ValueListenable listenSelectedRoom() => box.listenable(keys: [_ElectricityK.selectedRoom]); 59 | } 60 | 61 | class _ExpenseK { 62 | static const ns = "${SchoolSettings.ns}/expenseRecords"; 63 | static const autoRefresh = "$ns/autoRefresh"; 64 | } 65 | 66 | class _ExpenseRecords { 67 | final Box box; 68 | 69 | const _ExpenseRecords(this.box); 70 | 71 | bool get autoRefresh => box.safeGet(_ExpenseK.autoRefresh) ?? _kExpenseRecordsAutoRefresh; 72 | 73 | set autoRefresh(bool foo) => box.safePut(_ExpenseK.autoRefresh, foo); 74 | } 75 | -------------------------------------------------------------------------------- /lib/school/widget/campus.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:mimir/design/adaptive/multiplatform.dart'; 5 | import 'package:mimir/entity/campus.dart'; 6 | import 'package:mimir/settings/settings.dart'; 7 | import 'package:rettulf/rettulf.dart'; 8 | 9 | class CampusSelector extends ConsumerWidget { 10 | const CampusSelector({super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context, WidgetRef ref) { 14 | return SegmentedButton( 15 | segments: Campus.values 16 | .map((e) => ButtonSegment( 17 | icon: Icon(context.icons.location), 18 | value: e, 19 | label: e.l10n().text(), 20 | )) 21 | .toList(), 22 | selected: {ref.watch(Settings.$campus) ?? Campus.fengxian}, 23 | onSelectionChanged: (newSelection) async { 24 | ref.read(Settings.$campus.notifier).set(newSelection.first); 25 | await HapticFeedback.mediumImpact(); 26 | }, 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/school/widget/course.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:rettulf/rettulf.dart'; 3 | import 'package:mimir/school/entity/icon.dart'; 4 | 5 | class CourseIcon extends StatelessWidget { 6 | final String courseName; 7 | final double? size; 8 | final bool enabled; 9 | static const kDefaultSize = 45.0; 10 | 11 | const CourseIcon({ 12 | super.key, 13 | required this.courseName, 14 | this.enabled = true, 15 | this.size = kDefaultSize, 16 | }); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return Image.asset( 21 | CourseIcons.iconPathOf(courseName: courseName), 22 | width: size, 23 | height: size, 24 | color: enabled ? null : context.theme.disabledColor, 25 | ).sized(w: kDefaultSize, h: kDefaultSize); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/school/ywb/i18n.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:mimir/l10n/common.dart'; 3 | 4 | const i18n = _I18n(); 5 | 6 | class _I18n with CommonI18nMixin { 7 | const _I18n(); 8 | 9 | static const ns = "ywb"; 10 | 11 | final mine = const _Mine(); 12 | final details = const _Details(); 13 | final action = const _Action(); 14 | 15 | String get title => "$ns.title".tr(); 16 | 17 | String get info => "$ns.info".tr(); 18 | 19 | String get noServicesTip => "$ns.noServicesTip".tr(); 20 | } 21 | 22 | class _Mine { 23 | const _Mine(); 24 | 25 | static const ns = "${_I18n.ns}.mine"; 26 | 27 | String get title => "$ns.title".tr(); 28 | 29 | String get noApplicationsTip => "$ns.noApplicationsTip".tr(); 30 | } 31 | 32 | class _Details { 33 | const _Details(); 34 | 35 | static const ns = "${_I18n.ns}.details"; 36 | 37 | String get apply => "$ns.apply".tr(); 38 | } 39 | 40 | class _Action { 41 | const _Action(); 42 | 43 | static const ns = "${_I18n.ns}.action"; 44 | 45 | String get applications => "$ns.applications".tr(); 46 | } 47 | -------------------------------------------------------------------------------- /lib/school/ywb/index.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:go_router/go_router.dart'; 6 | import 'package:mimir/design/adaptive/multiplatform.dart'; 7 | import 'package:mimir/design/widget/app.dart'; 8 | import 'package:mimir/school/ywb/entity/application.dart'; 9 | import 'package:mimir/school/ywb/init.dart'; 10 | import 'package:rettulf/rettulf.dart'; 11 | 12 | import "i18n.dart"; 13 | import 'widget/application.dart'; 14 | 15 | const _applicationLength = 2; 16 | 17 | class YwbAppCard extends ConsumerStatefulWidget { 18 | const YwbAppCard({super.key}); 19 | 20 | @override 21 | ConsumerState createState() => _YwbAppCardState(); 22 | } 23 | 24 | class _YwbAppCardState extends ConsumerState { 25 | @override 26 | Widget build(BuildContext context) { 27 | final storage = YwbInit.applicationStorage; 28 | final family = storage.$applicationOf(YwbApplicationType.running); 29 | final running = ref.watch(family); 30 | return AppCard( 31 | title: i18n.title.text(), 32 | view: running == null ? null : buildRunningCard(running), 33 | leftActions: [ 34 | FilledButton.icon( 35 | onPressed: () { 36 | context.push("/ywb"); 37 | }, 38 | icon: const Icon(Icons.list_alt), 39 | label: i18n.seeAll.text(), 40 | ), 41 | FilledButton.tonalIcon( 42 | onPressed: () { 43 | context.push("/ywb/mine"); 44 | }, 45 | label: i18n.action.applications.text(), 46 | icon: Icon(context.icons.mail), 47 | ) 48 | ], 49 | ); 50 | } 51 | 52 | Widget buildRunningCard(List running) { 53 | final applications = running.sublist(0, min(_applicationLength, running.length)); 54 | return applications 55 | .map((e) => YwbApplicationTile(e).inCard( 56 | clip: Clip.hardEdge, 57 | )) 58 | .toList() 59 | .column(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/school/ywb/init.dart: -------------------------------------------------------------------------------- 1 | import 'service/service.dart'; 2 | import 'service/application.dart'; 3 | import 'storage/service.dart'; 4 | import 'storage/application.dart'; 5 | 6 | class YwbInit { 7 | static late YwbServiceService serviceService; 8 | static late YwbServiceStorage serviceStorage; 9 | static late YwbApplicationService applicationService; 10 | static late YwbApplicationStorage applicationStorage; 11 | 12 | static void init() { 13 | serviceService = const YwbServiceService(); 14 | applicationService = const YwbApplicationService(); 15 | } 16 | 17 | static void initStorage() { 18 | applicationStorage = YwbApplicationStorage(); 19 | serviceStorage = const YwbServiceStorage(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/school/ywb/service/service.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:mimir/init.dart'; 3 | 4 | import 'package:mimir/session/ywb.dart'; 5 | 6 | import '../entity/service.dart'; 7 | 8 | const String _serviceFunctionList = '${YwbSession.base}/app/public/queryAppManageJson'; 9 | const String _serviceFunctionDetail = '${YwbSession.base}/app/public/queryAppFormJson'; 10 | 11 | class YwbServiceService { 12 | YwbSession get _session => Init.ywbSession; 13 | 14 | const YwbServiceService(); 15 | 16 | Future> getServices() async { 17 | final response = await _session.request( 18 | _serviceFunctionList, 19 | data: '{"appObject":"student","appName":null}', 20 | options: Options( 21 | responseType: ResponseType.json, 22 | method: "POST", 23 | ), 24 | ); 25 | 26 | final Map data = response.data; 27 | final List functionList = (data['value'] as List) 28 | .map((e) => YwbService.fromJson(e)) 29 | .where((element) => element.status == 1) // Filter functions unavailable. 30 | .toList(); 31 | 32 | return functionList; 33 | } 34 | 35 | Future getServiceDetails(String functionId) async { 36 | final response = await _session.request( 37 | _serviceFunctionDetail, 38 | data: '{"appID":"$functionId"}', 39 | options: Options( 40 | responseType: ResponseType.json, 41 | method: "POST", 42 | ), 43 | ); 44 | final Map data = response.data; 45 | final List sections = 46 | (data['value'] as List).map((e) => YwbServiceDetailSection.fromJson(e)).toList(); 47 | 48 | return YwbServiceDetails(id: functionId, sections: sections); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/school/ywb/storage/application.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:hive_flutter/hive_flutter.dart'; 3 | import 'package:mimir/utils/hive.dart'; 4 | import 'package:mimir/storage/hive/init.dart'; 5 | 6 | import '../entity/application.dart'; 7 | 8 | class _K { 9 | static const ns = "/application"; 10 | 11 | static String applicationListOf(YwbApplicationType type) => "$ns/$type"; 12 | } 13 | 14 | class YwbApplicationStorage { 15 | Box get box => HiveInit.ywb; 16 | 17 | YwbApplicationStorage(); 18 | 19 | List? getApplicationListOf(YwbApplicationType type) => 20 | box.safeGet(_K.applicationListOf(type))?.cast(); 21 | 22 | Future setApplicationListOf(YwbApplicationType type, List? newV) => 23 | box.safePut(_K.applicationListOf(type), newV); 24 | 25 | Listenable listenApplicationListOf(YwbApplicationType type) => box.listenable(keys: [_K.applicationListOf(type)]); 26 | 27 | late final $applicationOf = box.providerFamily, YwbApplicationType>( 28 | _K.applicationListOf, 29 | get: getApplicationListOf, 30 | set: setApplicationListOf, 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /lib/school/ywb/storage/service.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | import 'package:mimir/storage/hive/init.dart'; 3 | import 'package:mimir/utils/hive.dart'; 4 | 5 | import '../entity/service.dart'; 6 | 7 | class _K { 8 | static const ns = "/meta"; 9 | static const serviceList = "$ns/serviceList"; 10 | 11 | static String details(String applicationId) => "$ns/details/$applicationId"; 12 | } 13 | 14 | class YwbServiceStorage { 15 | Box get box => HiveInit.ywb; 16 | 17 | const YwbServiceStorage(); 18 | 19 | YwbServiceDetails? getServiceDetails(String applicationId) => 20 | box.safeGet(_K.details(applicationId)); 21 | 22 | void setMetaDetails(String applicationId, YwbServiceDetails? newV) => 23 | box.safePut(_K.details(applicationId), newV); 24 | 25 | List? get serviceList => box.safeGet(_K.serviceList)?.cast(); 26 | 27 | set serviceList(List? newV) => box.safePut(_K.serviceList, newV); 28 | } 29 | -------------------------------------------------------------------------------- /lib/school/ywb/widget/application.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mimir/design/adaptive/multiplatform.dart'; 3 | import 'package:mimir/design/widget/expansion_tile.dart'; 4 | import 'package:mimir/l10n/extension.dart'; 5 | import 'package:rettulf/rettulf.dart'; 6 | 7 | import '../entity/application.dart'; 8 | 9 | class YwbApplicationTile extends StatelessWidget { 10 | final YwbApplication application; 11 | 12 | const YwbApplicationTile( 13 | this.application, { 14 | super.key, 15 | }); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return AnimatedExpansionTile( 20 | title: "${application.name} #${application.workId}".text(), 21 | subtitle: context.formatYmdWeekText(application.startTs).text(), 22 | children: application.track.map((e) => YwbApplicationTrackTile(e)).toList(), 23 | ); 24 | } 25 | } 26 | 27 | class YwbApplicationTrackTile extends StatelessWidget { 28 | final YwbApplicationTrack track; 29 | 30 | const YwbApplicationTrackTile( 31 | this.track, { 32 | super.key, 33 | }); 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return ListTile( 38 | isThreeLine: true, 39 | leading: track.isActionOk 40 | ? Icon(context.icons.checkMark, color: Colors.green) 41 | : Icon(context.icons.error, color: Colors.redAccent), 42 | title: track.step.text(), 43 | subtitle: [ 44 | context.formatYmdhmNum(track.timestamp).text(), 45 | if (track.message.isNotEmpty) track.message.text(), 46 | track.action.text(), 47 | ].column(caa: CrossAxisAlignment.start), 48 | trailing: track.senderName.text(), 49 | ); 50 | } 51 | } 52 | 53 | // final String resultUrl = 54 | // 'https://ywb.sit.edu.cn/unifri-flow/WF/mobile/index.html?ismobile=1&FK_Flow=${msg.functionId}&WorkID=${msg.workId}&IsReadonly=1&IsView=1'; 55 | // Navigator.of(context) 56 | // .push(MaterialPageRoute(builder: (_) => YwbInAppViewPage(title: msg.name, url: resultUrl))); 57 | -------------------------------------------------------------------------------- /lib/school/ywb/widget/detail.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mimir/session/ywb.dart'; 5 | import 'package:mimir/widget/html.dart'; 6 | import 'package:rettulf/rettulf.dart'; 7 | 8 | import '../entity/service.dart'; 9 | 10 | class YwbApplicationDetailSectionBlock extends StatelessWidget { 11 | final YwbServiceDetailSection section; 12 | 13 | const YwbApplicationDetailSectionBlock(this.section, {super.key}); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | final bodyWidget = switch (section.type) { 18 | 'html' => buildHtmlSection(section.content), 19 | 'json' => buildJsonSection(section.content), 20 | _ => const SizedBox.shrink(), 21 | }; 22 | 23 | return Padding( 24 | padding: const EdgeInsets.fromLTRB(10, 5, 10, 5), 25 | child: Column( 26 | crossAxisAlignment: CrossAxisAlignment.start, 27 | children: [ 28 | Text(section.section, style: context.textTheme.headlineSmall), 29 | bodyWidget, 30 | ], 31 | ), 32 | ); 33 | } 34 | 35 | Widget buildJsonSection(String content) { 36 | final Map pairs = jsonDecode(content); 37 | return pairs.entries.map((e) => '${e.key}: ${e.value}'.text()).toList().column(); 38 | } 39 | 40 | Widget buildHtmlSection(String content) { 41 | // TODO: cannot download pdf files 42 | final html = content.replaceAll('../app/files/', '${YwbSession.base}/app/files/'); 43 | return RestyledHtmlWidget( 44 | html, 45 | async: false, 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/school/ywb/widget/service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:go_router/go_router.dart'; 5 | import 'package:rettulf/rettulf.dart'; 6 | 7 | import '../entity/service.dart'; 8 | 9 | const List _serviceColors = [ 10 | Colors.orangeAccent, 11 | Colors.redAccent, 12 | Colors.blueAccent, 13 | Colors.grey, 14 | Colors.green, 15 | Colors.yellowAccent, 16 | Colors.cyan, 17 | Colors.purple, 18 | Colors.teal, 19 | ]; 20 | 21 | class YwbServiceTile extends StatelessWidget { 22 | final YwbService meta; 23 | final bool isHot; 24 | 25 | const YwbServiceTile({super.key, required this.meta, required this.isHot}); 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | final colorIndex = Random(meta.id.hashCode).nextInt(_serviceColors.length); 30 | final color = _serviceColors[colorIndex]; 31 | final style = context.textTheme.bodyMedium; 32 | final views = isHot 33 | ? [ 34 | Text(meta.count.toString(), style: style), 35 | const Icon( 36 | Icons.local_fire_department_rounded, 37 | color: Colors.red, 38 | ), 39 | ].row(mas: MainAxisSize.min) 40 | : Text(meta.count.toString(), style: style); 41 | 42 | return ListTile( 43 | leading: Icon(meta.icon, size: 35, color: color).center().sized(w: 40, h: 40), 44 | title: Text( 45 | meta.name, 46 | overflow: TextOverflow.ellipsis, 47 | ), 48 | subtitle: Text( 49 | meta.summary, 50 | overflow: TextOverflow.ellipsis, 51 | ), 52 | trailing: views, 53 | onTap: () { 54 | context.push("/ywb/details", extra: meta); 55 | }, 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/session/freshman.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/credentials/init.dart'; 2 | import 'package:mimir/init.dart'; 3 | import 'package:mimir/session/sso.dart'; 4 | import 'package:dio/dio.dart'; 5 | import 'package:mimir/utils/dio.dart'; 6 | 7 | class FreshmanSession { 8 | SsoSession get _ssoSession => Init.ssoSession; 9 | 10 | Dio get _dio => Init.schoolDio; 11 | 12 | const FreshmanSession(); 13 | 14 | Future request( 15 | String url, { 16 | Map? queryParameters, 17 | dynamic Function()? data, 18 | Options? options, 19 | ProgressCallback? onSendProgress, 20 | ProgressCallback? onReceiveProgress, 21 | }) async { 22 | Future fetch() async { 23 | return await _dio.request( 24 | url, 25 | queryParameters: queryParameters, 26 | data: data?.call(), 27 | options: (options ?? Options()).copyWith( 28 | followRedirects: false, 29 | validateStatus: (status) => status! < 400, 30 | ), 31 | onSendProgress: onSendProgress, 32 | onReceiveProgress: onReceiveProgress, 33 | ); 34 | } 35 | 36 | var res = await fetch(); 37 | if (_isLoginRequired(res)) { 38 | final account = CredentialsInit.storage.oa.credentials!.account; 39 | await _ssoSession.ssoAuth("http://freshman.sit.edu.cn/yyyx/sso/login.jsp"); 40 | await _dio.requestFollowRedirect( 41 | "http://freshman.sit.edu.cn/yyyx/loginAction.do?method=login&name=$account&password=111111", 42 | options: Options( 43 | method: "POST", 44 | contentType: Headers.formUrlEncodedContentType, 45 | ), 46 | ); 47 | } 48 | res = await fetch(); 49 | return res; 50 | } 51 | 52 | bool _isLoginRequired(Response response) { 53 | if (response.statusCode == 302) return true; 54 | final data = response.data; 55 | if (data is String && data.contains("请输入用户名和密码")) { 56 | return true; 57 | } 58 | return false; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/session/pg_registration.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | 3 | import 'package:mimir/session/sso.dart'; 4 | 5 | /// gms.sit.edu.cn 6 | /// Student registration system for postgraduate 7 | class PgRegistrationSession { 8 | final SsoSession ssoSession; 9 | 10 | const PgRegistrationSession({required this.ssoSession}); 11 | 12 | Future request( 13 | String url, { 14 | Map? para, 15 | dynamic Function()? data, 16 | Options? options, 17 | ProgressCallback? onSendProgress, 18 | ProgressCallback? onReceiveProgress, 19 | }) async { 20 | Future fetch() async { 21 | return await ssoSession.request( 22 | url, 23 | queryParameters: para, 24 | data: data, 25 | options: (options ?? Options()).copyWith( 26 | contentType: Headers.formUrlEncodedContentType, 27 | ), 28 | onSendProgress: onSendProgress, 29 | onReceiveProgress: onReceiveProgress, 30 | ); 31 | } 32 | 33 | final response = await fetch(); 34 | final content = response.data; 35 | if (content is String && content.contains("正在登录") == true) { 36 | await authGmsService(); 37 | return await fetch(); 38 | } 39 | return response; 40 | } 41 | 42 | Future authGmsService() async { 43 | final authRes = await ssoSession.request( 44 | "https://authserver.sit.edu.cn/authserver/login?service=http%3A%2F%2Fgms.sit.edu.cn%2Fepstar%2Fweb%2Fswms%2Fmainframe%2Fhome%2Findex.jsp", 45 | ); 46 | return authRes.statusCode == 302; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/settings/meta.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/utils/hive.dart'; 2 | import 'package:hive_flutter/hive_flutter.dart'; 3 | 4 | class _K { 5 | static const lastLaunchTime = "/lastLaunchTime"; 6 | static const thisLaunchTime = "/thisLaunchTime"; 7 | static const userRealName = "/userRealName"; 8 | } 9 | 10 | // ignore: non_constant_identifier_names 11 | late MetaImpl Meta; 12 | 13 | class MetaImpl { 14 | final Box box; 15 | 16 | const MetaImpl(this.box); 17 | 18 | DateTime? get lastLaunchTime => box.safeGet(_K.lastLaunchTime); 19 | 20 | set lastLaunchTime(DateTime? newV) => box.safePut(_K.lastLaunchTime, newV); 21 | 22 | DateTime? get thisLaunchTime => box.safeGet(_K.thisLaunchTime); 23 | 24 | set thisLaunchTime(DateTime? newV) => box.safePut(_K.thisLaunchTime, newV); 25 | 26 | String? get userRealName => box.safeGet(_K.userRealName); 27 | 28 | set userRealName(String? newV) => box.safePut(_K.userRealName, newV); 29 | } 30 | -------------------------------------------------------------------------------- /lib/settings/widget/device.dart: -------------------------------------------------------------------------------- 1 | import 'package:device_info_plus/device_info_plus.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:simple_icons/simple_icons.dart'; 4 | import 'package:mimir/entity/meta.dart'; 5 | 6 | IconData getDeviceIcon(AppMeta meta, [BaseDeviceInfo? info]) { 7 | switch (meta.platform) { 8 | case AppPlatform.iOS: 9 | case AppPlatform.macOS: 10 | return SimpleIcons.apple; 11 | case AppPlatform.android: 12 | if (info is AndroidDeviceInfo) { 13 | return _getAndroidIcon(info); 14 | } 15 | return SimpleIcons.android; 16 | case AppPlatform.windows: 17 | return SimpleIcons.linux; 18 | case AppPlatform.linux: 19 | return SimpleIcons.windows; 20 | case AppPlatform.web: 21 | return Icons.web; 22 | case AppPlatform.unknown: 23 | return Icons.device_unknown_outlined; 24 | } 25 | } 26 | 27 | const _manufacturer2icon = { 28 | "xiaomi": SimpleIcons.xiaomi, 29 | "huawei": SimpleIcons.huawei, 30 | "oppo": SimpleIcons.oppo, 31 | "sony": SimpleIcons.sony, 32 | "oneplus": SimpleIcons.oneplus, 33 | "samsung": SimpleIcons.samsung, 34 | "google": SimpleIcons.google, 35 | }; 36 | 37 | IconData _getAndroidIcon(AndroidDeviceInfo info) { 38 | final manufacturer = info.manufacturer.toLowerCase(); 39 | for (final MapEntry(key: mf, value: icon) in _manufacturer2icon.entries) { 40 | if (manufacturer.contains(mf)) { 41 | return icon; 42 | } 43 | } 44 | 45 | return SimpleIcons.android; 46 | } 47 | -------------------------------------------------------------------------------- /lib/storage/hive/builtin.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:version/version.dart'; 3 | 4 | import 'type_id.dart'; 5 | 6 | class VersionAdapter extends TypeAdapter { 7 | @override 8 | final int typeId = CoreHiveType.version; 9 | 10 | @override 11 | Version read(BinaryReader reader) { 12 | final major = reader.readInt(); 13 | final minor = reader.readInt(); 14 | final patch = reader.readInt(); 15 | final build = reader.readString(); 16 | return Version(major, minor, patch, build: build); 17 | } 18 | 19 | @override 20 | void write(BinaryWriter writer, Version obj) { 21 | writer.writeInt(obj.major); 22 | writer.writeInt(obj.minor); 23 | writer.writeInt(obj.patch); 24 | writer.writeString(obj.build); 25 | } 26 | 27 | @override 28 | int get hashCode => typeId.hashCode; 29 | 30 | @override 31 | bool operator ==(Object other) => 32 | identical(this, other) || other is VersionAdapter && runtimeType == other.runtimeType && typeId == other.typeId; 33 | } 34 | 35 | class ThemeModeAdapter extends TypeAdapter { 36 | @override 37 | final int typeId = CoreHiveType.themeMode; 38 | 39 | @override 40 | ThemeMode read(BinaryReader reader) { 41 | final index = reader.readInt32(); 42 | return ThemeMode.values[index]; 43 | } 44 | 45 | @override 46 | void write(BinaryWriter writer, ThemeMode obj) { 47 | writer.writeInt32(obj.index); 48 | } 49 | 50 | @override 51 | int get hashCode => typeId.hashCode; 52 | 53 | @override 54 | bool operator ==(Object other) => 55 | identical(this, other) || other is ThemeModeAdapter && runtimeType == other.runtimeType && typeId == other.typeId; 56 | } 57 | 58 | /// There is no need to consider revision 59 | class SizeAdapter extends TypeAdapter { 60 | @override 61 | final int typeId = CoreHiveType.size; 62 | 63 | @override 64 | Size read(BinaryReader reader) { 65 | var x = reader.readDouble(); 66 | var y = reader.readDouble(); 67 | return Size(x, y); 68 | } 69 | 70 | @override 71 | void write(BinaryWriter writer, Size obj) { 72 | writer.writeDouble(obj.width); 73 | writer.writeDouble(obj.height); 74 | } 75 | 76 | @override 77 | int get hashCode => typeId.hashCode; 78 | 79 | @override 80 | bool operator ==(Object other) => 81 | identical(this, other) || other is SizeAdapter && runtimeType == other.runtimeType && typeId == other.typeId; 82 | } 83 | -------------------------------------------------------------------------------- /lib/storage/hive/cookie.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:mimir/r.dart'; 3 | import 'package:mimir/utils/hive.dart'; 4 | import 'package:cookie_jar/cookie_jar.dart'; 5 | import 'package:hive/hive.dart'; 6 | 7 | class HiveCookieJar implements Storage { 8 | final Box box; 9 | 10 | const HiveCookieJar(this.box); 11 | 12 | @override 13 | Future init(bool persistSession, bool ignoreExpires) async { 14 | if (R.debugNetwork) { 15 | debugPrint("[$HiveCookieJar] persistSession=$persistSession, ignoreExpires=$ignoreExpires"); 16 | } 17 | } 18 | 19 | @override 20 | Future write(String key, String value) async { 21 | if (R.debugNetwork) { 22 | debugPrint("[$HiveCookieJar] Writing cookie: $key=$value."); 23 | } 24 | await box.safePut(key, value); 25 | } 26 | 27 | @override 28 | Future read(String key) async { 29 | if (R.debugNetwork) { 30 | debugPrint("[$HiveCookieJar] Reading cookie: $key."); 31 | } 32 | final value = box.safeGet(key); 33 | if (R.debugNetwork) { 34 | debugPrint("[$HiveCookieJar] Read cookie: $key=$value."); 35 | } 36 | return value; 37 | } 38 | 39 | @override 40 | Future delete(String key) async { 41 | if (R.debugNetwork) { 42 | debugPrint("[$HiveCookieJar] Deleting cookie: $key."); 43 | } 44 | await box.delete(key); 45 | } 46 | 47 | @override 48 | Future deleteAll(List keys) async { 49 | if (R.debugNetwork) { 50 | debugPrint("[$HiveCookieJar] Deleting all cookies: $keys."); 51 | } 52 | await box.deleteAll(keys); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/storage/prefs.dart: -------------------------------------------------------------------------------- 1 | import 'package:shared_preferences/shared_preferences.dart'; 2 | import 'package:mimir/r.dart'; 3 | 4 | class _K { 5 | static const lastVersion = "${R.appId}.lastVersion"; 6 | static const installTime = "${R.appId}.installTime"; 7 | static const uuid = "${R.appId}.uuid"; 8 | } 9 | 10 | extension PrefsX on SharedPreferences { 11 | String? getLastVersion() => getString(_K.lastVersion); 12 | 13 | Future setLastVersion(String value) => setString(_K.lastVersion, value); 14 | 15 | /// The first time when user launch this app 16 | DateTime? getInstallTime() { 17 | final raw = getString(_K.installTime); 18 | if (raw == null) return null; 19 | return DateTime.tryParse(raw); 20 | } 21 | 22 | Future setInstallTime(DateTime value) => setString(_K.installTime, value.toString()); 23 | 24 | String? getUuid() => getString(_K.uuid); 25 | 26 | Future setUuid(String value) => setString(_K.uuid, value); 27 | } 28 | -------------------------------------------------------------------------------- /lib/timetable/entity/course.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'course.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | UndergraduateCourseRaw _$UndergraduateCourseRawFromJson(Map json) => UndergraduateCourseRaw( 10 | courseName: json['kcmc'] as String, 11 | weekDayText: json['xqjmc'] as String, 12 | timeslotsText: json['jcs'] as String, 13 | weekText: json['zcd'] as String, 14 | place: json['cdmc'] as String, 15 | teachers: json['xm'] as String? ?? '', 16 | campus: json['xqmc'] as String, 17 | courseCredit: json['xf'] as String, 18 | creditHour: json['zxs'] as String, 19 | classCode: json['jxbmc'] as String, 20 | courseCode: json['kch'] as String, 21 | ); 22 | -------------------------------------------------------------------------------- /lib/timetable/entity/display.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | 3 | /// 课表显示模式 4 | enum DisplayMode { 5 | weekly, 6 | daily; 7 | 8 | static DisplayMode? at(int? index) { 9 | if (index == null) { 10 | return null; 11 | } else if (0 <= index && index < DisplayMode.values.length) { 12 | return DisplayMode.values[index]; 13 | } 14 | return null; 15 | } 16 | 17 | DisplayMode toggle() => DisplayMode.values[(index + 1) & 1]; 18 | 19 | String l10n() => "timetable.displayMode.$name".tr(); 20 | } 21 | -------------------------------------------------------------------------------- /lib/timetable/entity/issue.dart: -------------------------------------------------------------------------------- 1 | import 'package:mimir/l10n/time.dart'; 2 | import 'package:statistics/statistics.dart'; 3 | 4 | import 'timetable.dart'; 5 | 6 | enum TimetableIssueType { 7 | empty, 8 | cbeCourse, 9 | courseOverlaps, 10 | ; 11 | } 12 | 13 | sealed class TimetableIssue { 14 | TimetableIssueType get type; 15 | } 16 | 17 | class TimetableEmptyIssue implements TimetableIssue { 18 | @override 19 | TimetableIssueType get type => TimetableIssueType.empty; 20 | 21 | const TimetableEmptyIssue(); 22 | } 23 | 24 | /// Credit by Examination 25 | class TimetableCbeIssue implements TimetableIssue { 26 | @override 27 | TimetableIssueType get type => TimetableIssueType.cbeCourse; 28 | final int courseKey; 29 | 30 | const TimetableCbeIssue({ 31 | required this.courseKey, 32 | }); 33 | 34 | static bool detect(Course course) { 35 | if (course.courseName.contains("自修") || course.courseName.contains("免听")) { 36 | return true; 37 | } 38 | return false; 39 | } 40 | } 41 | 42 | class TimetableCourseOverlapIssue implements TimetableIssue { 43 | @override 44 | TimetableIssueType get type => TimetableIssueType.courseOverlaps; 45 | final List courseKeys; 46 | final int weekIndex; 47 | final Weekday weekday; 48 | final ({int start, int end}) timeslots; 49 | 50 | const TimetableCourseOverlapIssue({ 51 | required this.courseKeys, 52 | required this.weekIndex, 53 | required this.weekday, 54 | required this.timeslots, 55 | }); 56 | 57 | bool isSameOne(TimetableCourseOverlapIssue other) { 58 | if (courseKeys.toSet().equalsElements(other.courseKeys.toSet())) { 59 | return true; 60 | } 61 | return false; 62 | } 63 | } 64 | 65 | extension Timetable4IssueX on Timetable { 66 | List inspect() { 67 | final issues = []; 68 | // check if empty 69 | if (courses.isEmpty) { 70 | issues.add(const TimetableEmptyIssue()); 71 | } 72 | 73 | // check if any cbe 74 | for (final course in courses.values) { 75 | if (!course.hidden && TimetableCbeIssue.detect(course)) { 76 | issues.add(TimetableCbeIssue( 77 | courseKey: course.courseKey, 78 | )); 79 | } 80 | } 81 | return issues; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/timetable/entity/loc.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'loc.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | TimetableDayLoc _$TimetableDayLocFromJson(Map json) => TimetableDayLoc( 10 | mode: $enumDecode(_$TimetableDayLocModeEnumMap, json['mode']), 11 | posInternal: json['pos'] == null ? null : TimetablePos.fromJson(json['pos'] as Map), 12 | dateInternal: json['date'] == null ? null : DateTime.parse(json['date'] as String), 13 | ); 14 | 15 | Map _$TimetableDayLocToJson(TimetableDayLoc instance) => { 16 | 'mode': _$TimetableDayLocModeEnumMap[instance.mode]!, 17 | if (instance.posInternal case final value?) 'pos': value, 18 | if (instance.dateInternal?.toIso8601String() case final value?) 'date': value, 19 | }; 20 | 21 | const _$TimetableDayLocModeEnumMap = { 22 | TimetableDayLocMode.pos: 'pos', 23 | TimetableDayLocMode.date: 'date', 24 | }; 25 | -------------------------------------------------------------------------------- /lib/timetable/events.dart: -------------------------------------------------------------------------------- 1 | import 'package:event_bus/event_bus.dart'; 2 | 3 | import 'entity/pos.dart'; 4 | 5 | final eventBus = EventBus(); 6 | 7 | class JumpToPosEvent { 8 | final TimetablePos where; 9 | 10 | const JumpToPosEvent(this.where); 11 | } 12 | -------------------------------------------------------------------------------- /lib/timetable/init.dart: -------------------------------------------------------------------------------- 1 | import 'service/school.dart'; 2 | import 'storage/timetable.dart'; 3 | 4 | class TimetableInit { 5 | static late TimetableService service; 6 | static late TimetableStorage storage; 7 | 8 | static void init() { 9 | service = const TimetableService(); 10 | } 11 | 12 | static void initStorage() { 13 | storage = TimetableStorage(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/timetable/p13n/builtin.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mimir/design/entity/dual_color.dart'; 3 | import 'package:mimir/timetable/p13n/entity/palette.dart'; 4 | 5 | import '../entity/timetable.dart'; 6 | 7 | /// https://m3.material.io/theme-builder#/custom 8 | class BuiltinTimetablePalettes { 9 | static const classic = BuiltinTimetablePalette( 10 | uuid: "297a0b7c-ef53-4d5e-a76b-ac678f3e9386", 11 | key: "classic", 12 | author: "Li_plum@outlook.com", 13 | colors: [ 14 | DualColor(dark: ColorEntry(Color(0xdf356a21)), light: ColorEntry(Color(0xd2a7d99b))), 15 | DualColor(dark: ColorEntry(Color(0xdf00739b)), light: ColorEntry(Color(0xd2cbe3ef))), 16 | DualColor(dark: ColorEntry(Color(0xdf8e2f56)), light: ColorEntry(Color(0xd2ffa6bb))), 17 | DualColor(dark: ColorEntry(Color(0xdf50378a)), light: ColorEntry(Color(0xd2b8a4ea))), 18 | DualColor(dark: ColorEntry(Color(0xdfac5029)), light: ColorEntry(Color(0xd2ecab8a))), 19 | DualColor(dark: ColorEntry(Color(0xdf80002d)), light: ColorEntry(Color(0xd2eeb6d3))), 20 | DualColor(dark: ColorEntry(Color(0xdf7c5800)), light: ColorEntry(Color(0xd2eadea1))), 21 | DualColor(dark: ColorEntry(Color(0xdf006b5f)), light: ColorEntry(Color(0xd292c7b8))), 22 | DualColor(dark: ColorEntry(Color(0xdf004e5f)), light: ColorEntry(Color(0xd2aaccd8))), 23 | DualColor(dark: ColorEntry(Color(0xdf7c157a)), light: ColorEntry(Color(0xd2ffd7f5))), 24 | DualColor(dark: ColorEntry(Color(0xdf616200)), light: ColorEntry(Color(0xd2d9dc89))), 25 | ], 26 | ); 27 | 28 | static const all = [ 29 | classic, 30 | ]; 31 | 32 | static final uuid2palette = Map.fromEntries(all.map((p) => MapEntry(p.uuid, p))); 33 | } 34 | 35 | extension TimetablePlatteX on TimetablePalette { 36 | DualColor resolveColor(Course course) { 37 | assert(colors.isNotEmpty, "Colors can't be empty"); 38 | if (colors.isEmpty) return TimetablePalette.defaultColor; 39 | return colors[course.courseCode.hashCode.abs() % colors.length]; 40 | } 41 | 42 | DualColor safeGetColor(int index) { 43 | assert(colors.isNotEmpty, "Colors can't be empty"); 44 | if (colors.isEmpty) return TimetablePalette.defaultColor; 45 | return colors[index % colors.length]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/timetable/p13n/entity/cell_style.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:copy_with_extension/copy_with_extension.dart'; 4 | import 'package:dynamic_color/dynamic_color.dart'; 5 | import 'package:json_annotation/json_annotation.dart'; 6 | import 'package:mimir/utils/color.dart'; 7 | 8 | part "cell_style.g.dart"; 9 | 10 | @CopyWith(skipFields: true) 11 | @JsonSerializable() 12 | class CourseCellStyle { 13 | @JsonKey() 14 | final bool showTeachers; 15 | @JsonKey() 16 | final bool grayOutTakenLessons; 17 | @JsonKey() 18 | final bool harmonizeWithThemeColor; 19 | @JsonKey() 20 | final double alpha; 21 | 22 | const CourseCellStyle({ 23 | this.showTeachers = true, 24 | this.grayOutTakenLessons = false, 25 | this.harmonizeWithThemeColor = true, 26 | this.alpha = 1.0, 27 | }); 28 | 29 | Color decorateColor( 30 | Color color, { 31 | Color? themeColor, 32 | bool isLessonTaken = false, 33 | }) { 34 | final oldOpacity = color.opacity; 35 | // harmonizeWith will clear the opacity 36 | if (harmonizeWithThemeColor && themeColor != null) { 37 | color = color.harmonizeWith(themeColor); 38 | } 39 | if (grayOutTakenLessons && isLessonTaken) { 40 | color = color.monochrome(); 41 | } 42 | // restore the opacity 43 | color = color.withOpacity(oldOpacity); 44 | if (alpha < 1.0) { 45 | color = color.withOpacity(color.opacity * alpha); 46 | } 47 | return color; 48 | } 49 | 50 | factory CourseCellStyle.fromJson(Map json) => _$CourseCellStyleFromJson(json); 51 | 52 | Map toJson() => _$CourseCellStyleToJson(this); 53 | 54 | @override 55 | bool operator ==(Object other) { 56 | return identical(this, other) || 57 | other is CourseCellStyle && 58 | runtimeType == other.runtimeType && 59 | showTeachers == other.showTeachers && 60 | grayOutTakenLessons == other.grayOutTakenLessons && 61 | harmonizeWithThemeColor == other.harmonizeWithThemeColor && 62 | alpha == other.alpha; 63 | } 64 | 65 | @override 66 | int get hashCode => Object.hash(showTeachers, grayOutTakenLessons, harmonizeWithThemeColor, alpha); 67 | } 68 | -------------------------------------------------------------------------------- /lib/timetable/p13n/widget/style.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'style.dart'; 4 | 5 | // ************************************************************************** 6 | // CopyWithGenerator 7 | // ************************************************************************** 8 | 9 | abstract class _$TimetableStyleDataCWProxy { 10 | /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. 11 | /// 12 | /// Usage 13 | /// ```dart 14 | /// TimetableStyleData(...).copyWith(id: 12, name: "My name") 15 | /// ```` 16 | TimetableStyleData call({ 17 | TimetablePalette? platte, 18 | CourseCellStyle? cellStyle, 19 | }); 20 | } 21 | 22 | /// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfTimetableStyleData.copyWith(...)`. 23 | class _$TimetableStyleDataCWProxyImpl implements _$TimetableStyleDataCWProxy { 24 | const _$TimetableStyleDataCWProxyImpl(this._value); 25 | 26 | final TimetableStyleData _value; 27 | 28 | @override 29 | 30 | /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. 31 | /// 32 | /// Usage 33 | /// ```dart 34 | /// TimetableStyleData(...).copyWith(id: 12, name: "My name") 35 | /// ```` 36 | TimetableStyleData call({ 37 | Object? platte = const $CopyWithPlaceholder(), 38 | Object? cellStyle = const $CopyWithPlaceholder(), 39 | }) { 40 | return TimetableStyleData( 41 | platte: platte == const $CopyWithPlaceholder() || platte == null 42 | ? _value.platte 43 | // ignore: cast_nullable_to_non_nullable 44 | : platte as TimetablePalette, 45 | cellStyle: cellStyle == const $CopyWithPlaceholder() || cellStyle == null 46 | ? _value.cellStyle 47 | // ignore: cast_nullable_to_non_nullable 48 | : cellStyle as CourseCellStyle, 49 | ); 50 | } 51 | } 52 | 53 | extension $TimetableStyleDataCopyWith on TimetableStyleData { 54 | /// Returns a callable class that can be used as follows: `instanceOfTimetableStyleData.copyWith(...)`. 55 | // ignore: library_private_types_in_public_api 56 | _$TimetableStyleDataCWProxy get copyWith => _$TimetableStyleDataCWProxyImpl(this); 57 | } 58 | -------------------------------------------------------------------------------- /lib/timetable/page/index.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | 4 | import '../init.dart'; 5 | import '../p13n/widget/style.dart'; 6 | import 'mine.dart'; 7 | import 'timetable.dart'; 8 | import '../entity/timetable_entity.dart'; 9 | 10 | class TimetablePage extends ConsumerStatefulWidget { 11 | const TimetablePage({super.key}); 12 | 13 | @override 14 | ConsumerState createState() => _TimetablePageState(); 15 | } 16 | 17 | final $selectedTimetableEntity = Provider.autoDispose((ref) { 18 | final timetable = ref.watch(TimetableInit.storage.timetable.$selectedRow); 19 | return timetable?.resolve(); 20 | }); 21 | 22 | class _TimetablePageState extends ConsumerState { 23 | @override 24 | Widget build(BuildContext context) { 25 | final selected = ref.watch($selectedTimetableEntity); 26 | if (selected == null) { 27 | // If no timetable selected, navigate to Mine page to select/import one. 28 | return const MyTimetableListPage(); 29 | } else { 30 | return TimetableStyleProv( 31 | child: TimetableBoardPage( 32 | timetable: selected, 33 | ), 34 | ); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/timetable/settings.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:hive_flutter/hive_flutter.dart'; 3 | import 'package:mimir/utils/hive.dart'; 4 | import 'package:mimir/utils/json.dart'; 5 | 6 | import 'p13n/entity/cell_style.dart'; 7 | 8 | const _kAutoUseImported = true; 9 | const _kQuickLookCourseOnTap = true; 10 | 11 | class _K { 12 | static const ns = "/timetable"; 13 | static const autoUseImported = "$ns/autoUseImported"; 14 | static const cellStyle = "$ns/cellStyle"; 15 | static const quickLookLessonOnTap = "$ns/quickLookLessonOnTap"; 16 | } 17 | 18 | class TimetableSettings { 19 | final Box box; 20 | 21 | TimetableSettings(this.box); 22 | 23 | bool get autoUseImported => box.safeGet(_K.autoUseImported) ?? _kAutoUseImported; 24 | 25 | set autoUseImported(bool newV) => box.safePut(_K.autoUseImported, newV); 26 | 27 | late final $autoUseImported = box.providerWithDefault(_K.autoUseImported, () => _kAutoUseImported); 28 | 29 | CourseCellStyle? get cellStyle => decodeJsonObject( 30 | box.safeGet(_K.cellStyle), 31 | (obj) => CourseCellStyle.fromJson(obj), 32 | ); 33 | 34 | set cellStyle(CourseCellStyle? newV) => box.safePut( 35 | _K.cellStyle, 36 | encodeJsonObject(newV, (obj) => obj.toJson()), 37 | ); 38 | 39 | late final $cellStyle = box.provider( 40 | _K.cellStyle, 41 | get: () => cellStyle, 42 | set: (v) => cellStyle = v, 43 | ); 44 | 45 | ValueListenable listenCellStyle() => box.listenable(keys: [_K.cellStyle]); 46 | 47 | bool get quickLookLessonOnTap => box.safeGet(_K.quickLookLessonOnTap) ?? _kQuickLookCourseOnTap; 48 | 49 | set quickLookLessonOnTap(bool newV) => box.safePut(_K.quickLookLessonOnTap, newV); 50 | 51 | late final $quickLookLessonOnTap = 52 | box.providerWithDefault(_K.quickLookLessonOnTap, () => _kQuickLookCourseOnTap); 53 | } 54 | -------------------------------------------------------------------------------- /lib/timetable/storage/timetable.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive_flutter/hive_flutter.dart'; 2 | import 'package:mimir/storage/hive/init.dart'; 3 | import 'package:mimir/storage/hive/table.dart'; 4 | import 'package:mimir/utils/hive.dart'; 5 | import 'package:mimir/timetable/entity/timetable.dart'; 6 | 7 | import '../entity/display.dart'; 8 | import '../p13n/builtin.dart'; 9 | import '../p13n/entity/palette.dart'; 10 | 11 | class _K { 12 | static const timetable = "/timetable"; 13 | static const lastDisplayMode = "/lastDisplayMode"; 14 | static const palette = "/palette"; 15 | } 16 | 17 | class TimetableStorage { 18 | Box get box => HiveInit.timetable; 19 | 20 | final timetable = HiveTable.withUuid( 21 | base: _K.timetable, 22 | box: HiveInit.timetable, 23 | useJson: (fromJson: Timetable.fromJson, toJson: (timetable) => timetable.toJson()), 24 | ); 25 | 26 | final palette = HiveTable.withUuid( 27 | base: _K.palette, 28 | box: HiveInit.timetable, 29 | useJson: (fromJson: TimetablePalette.fromJson, toJson: (palette) => palette.toJson()), 30 | external: ExternalTable.unmodifiableMap(BuiltinTimetablePalettes.uuid2palette), 31 | ); 32 | 33 | TimetableStorage(); 34 | 35 | DisplayMode? get lastDisplayMode => DisplayMode.at(box.safeGet(_K.lastDisplayMode)); 36 | 37 | set lastDisplayMode(DisplayMode? newValue) => box.safePut(_K.lastDisplayMode, newValue?.index); 38 | } 39 | -------------------------------------------------------------------------------- /lib/timetable/utils/export.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/widgets.dart'; 4 | import 'package:mimir/design/adaptive/multiplatform.dart'; 5 | import 'package:mimir/files.dart'; 6 | import 'package:sanitize_filename/sanitize_filename.dart'; 7 | import 'package:share_plus/share_plus.dart'; 8 | 9 | import '../entity/timetable.dart'; 10 | 11 | Future exportTimetableFileAndShare( 12 | Timetable timetable, { 13 | required BuildContext context, 14 | }) async { 15 | final content = jsonEncode(timetable.toJson()); 16 | var fileName = "${timetable.name}.timetable"; 17 | if (timetable.signature.isNotEmpty) { 18 | fileName = "${timetable.signature} $fileName"; 19 | } 20 | fileName = sanitizeFilename(fileName, replacement: "-"); 21 | final timetableFi = Files.temp.subFile(fileName); 22 | final sharePositionOrigin = context.getSharePositionOrigin(); 23 | await timetableFi.writeAsString(content); 24 | await Share.shareXFiles( 25 | [XFile(timetableFi.path)], 26 | sharePositionOrigin: sharePositionOrigin, 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /lib/timetable/utils/freshman.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:mimir/design/adaptive/dialog.dart'; 4 | import '../i18n.dart'; 5 | 6 | Future onFreshmanImport(BuildContext context) async { 7 | final confirm = await context.showDialogRequest( 8 | title: "不支持此功能", 9 | desc: "你目前使用的迎新系统账号无法导入课程表,请使用学号重新登录后重试", 10 | primary: "重新登录", 11 | secondary: i18n.cancel, 12 | dismissible: false, 13 | ); 14 | if (confirm == true) { 15 | if (!context.mounted) return; 16 | context.push("/oa/login"); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/timetable/widget/focus.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:mimir/design/adaptive/menu.dart'; 4 | import 'package:mimir/design/adaptive/multiplatform.dart'; 5 | import 'package:mimir/school/i18n.dart' as $school; 6 | import 'package:mimir/settings/i18n.dart' as $settings; 7 | 8 | List buildFocusPopupActions(BuildContext context) { 9 | return [ 10 | PullDownItem( 11 | icon: Icons.school_outlined, 12 | title: $school.i18n.navigation, 13 | onTap: () async { 14 | await context.push("/school"); 15 | }, 16 | ), 17 | PullDownItem( 18 | icon: context.icons.settings, 19 | title: $settings.i18n.title, 20 | onTap: () async { 21 | await context.push("/settings"); 22 | }, 23 | ), 24 | ]; 25 | } 26 | -------------------------------------------------------------------------------- /lib/timetable/widget/timetable/board.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:rettulf/rettulf.dart'; 4 | 5 | import '../../entity/display.dart'; 6 | import '../../entity/pos.dart'; 7 | import '../../entity/timetable_entity.dart'; 8 | import 'daily.dart'; 9 | import 'weekly.dart'; 10 | 11 | class TimetableBoard extends ConsumerWidget { 12 | final TimetableEntity timetable; 13 | final ValueNotifier $displayMode; 14 | final ValueNotifier $currentPos; 15 | 16 | const TimetableBoard({ 17 | super.key, 18 | required this.timetable, 19 | required this.$displayMode, 20 | required this.$currentPos, 21 | }); 22 | 23 | @override 24 | Widget build(BuildContext context, WidgetRef ref) { 25 | return $displayMode >> 26 | (ctx, mode) => AnimatedSwitcher( 27 | duration: Durations.short4, 28 | child: mode == DisplayMode.daily 29 | ? DailyTimetable( 30 | $currentPos: $currentPos, 31 | timetable: timetable, 32 | ) 33 | : WeeklyTimetable( 34 | $currentPos: $currentPos, 35 | timetable: timetable, 36 | ), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/utils/collection.dart: -------------------------------------------------------------------------------- 1 | extension ListX on List { 2 | List distinct({bool inplace = true}) { 3 | final ids = {}; 4 | var list = inplace ? this : List.from(this); 5 | list.retainWhere((x) => ids.add(x)); 6 | return list; 7 | } 8 | 9 | List distinctBy(Id Function(E element) id, {bool inplace = true}) { 10 | final ids = {}; 11 | var list = inplace ? this : List.from(this); 12 | list.retainWhere((x) => ids.add(id(x))); 13 | return list; 14 | } 15 | 16 | /// Accesses elements using a negative index similar to Python. 17 | /// A negative index counts from the end of the list. 18 | /// 19 | /// Throws an [ArgumentError] if the index is out of bounds. 20 | E indexAt(int index) { 21 | if (index < 0) { 22 | final absIndex = index.abs(); 23 | if (absIndex > length) { 24 | throw ArgumentError("List index out of range: $index"); 25 | } 26 | return this[length + index]; 27 | } else { 28 | return this[index]; 29 | } 30 | } 31 | } 32 | 33 | extension IterableX on Iterable { 34 | E? maxByOrNull>(T Function(E) valueOf) { 35 | final it = iterator; 36 | if (it.moveNext()) { 37 | final first = it.current; 38 | var tempMax = valueOf(first); 39 | var tempE = first; 40 | while (it.moveNext()) { 41 | final cur = it.current; 42 | final curValue = valueOf(cur); 43 | if (curValue.compareTo(tempMax) > 0) { 44 | tempMax = curValue; 45 | tempE = cur; 46 | } 47 | } 48 | return tempE; 49 | } 50 | return null; 51 | } 52 | 53 | E maxBy>(T Function(E) valueOf) { 54 | return maxByOrNull(valueOf)!; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/utils/color.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | import 'package:collection/collection.dart'; 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | import 'package:flutter/foundation.dart' show defaultTargetPlatform; 6 | import 'package:flutter/material.dart'; 7 | import 'package:system_theme/system_theme.dart'; 8 | 9 | extension ColorX on Color { 10 | Color monochrome({double progress = 1}) { 11 | final gray = 0.21 * r + 0.71 * g + 0.07 * b; 12 | final iProgress = 1.0 - progress; 13 | return Color.from( 14 | alpha: a, 15 | red: r * iProgress + gray * progress, 16 | green: g * iProgress + gray * progress, 17 | blue: b * iProgress + gray * progress, 18 | ); 19 | } 20 | 21 | double get luminance => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0; 22 | 23 | double get brightness => (0.299 * r + 0.587 * g + 0.114 * b) / 255.0; 24 | } 25 | 26 | extension SystemAccentColorX on SystemAccentColor { 27 | Color? get maybeAccent => supportsSystemAccentColor ? accent : null; 28 | } 29 | 30 | bool get supportsSystemAccentColor => defaultTargetPlatform.supportsAccentColor; 31 | 32 | Color? findBestTextColor(Color bgColor, List textColors) { 33 | // final sorted = textColors.sortedBy((textColor) => calculateContrastRatio(textColor, bgColor)); 34 | final map = 35 | Map.fromEntries(textColors.map((textColor) => MapEntry(calculateContrastRatio(textColor, bgColor), textColor))); 36 | final sorted = map.entries.sortedBy((e) => e.key).toList(); 37 | final res = sorted.lastOrNull?.value; 38 | return res; 39 | } 40 | 41 | double calculateContrastRatio(Color color1, Color color2) { 42 | final luminance1 = color1.luminance; 43 | final luminance2 = color2.luminance; 44 | final contrast = (luminance1 + 0.05) / (luminance2 + 0.05); 45 | if (contrast < 1) { 46 | return 1 / contrast; 47 | } 48 | return contrast; 49 | } 50 | -------------------------------------------------------------------------------- /lib/utils/error.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:flutter/cupertino.dart'; 5 | import 'package:mimir/credentials/error.dart'; 6 | import 'package:mimir/design/adaptive/dialog.dart'; 7 | import 'package:mimir/lifecycle.dart'; 8 | import 'package:mimir/login/i18n.dart'; 9 | 10 | void debugPrintError(Object? error, [StackTrace? stackTrace]) { 11 | if (error == null) { 12 | return; 13 | } else if (error is DioException) { 14 | debugPrint(error.toString()); 15 | debugPrintStack(stackTrace: error.stackTrace, maxFrames: 10); 16 | } else if (error is AsyncError) { 17 | debugPrintError(error.error, error.stackTrace); 18 | } else if (error is ParallelWaitError) { 19 | final errors = error.errors; 20 | if (errors is (AsyncError?, AsyncError?)) { 21 | debugPrintError(errors.$1); 22 | debugPrintError(errors.$2); 23 | } else { 24 | debugPrint(errors.toString()); 25 | } 26 | } else { 27 | debugPrint(error.toString()); 28 | debugPrintStack(stackTrace: stackTrace); 29 | } 30 | } 31 | 32 | const _i18n = CommonAuthI18n(); 33 | 34 | Future handleRequestError(Object? error, [StackTrace? stackTrace]) async { 35 | debugPrintError(error, stackTrace); 36 | final context = $key.currentContext; 37 | if (error is CredentialException) { 38 | if (context == null || context.mounted) return; 39 | await context.showTip( 40 | serious: true, 41 | title: _i18n.failedWarn, 42 | desc: error.type.l10n(), 43 | primary: _i18n.close, 44 | ); 45 | return; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/utils/format.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | 3 | String formatWithoutTrailingZeros( 4 | double amount, { 5 | int fractionDigits = 2, 6 | }) { 7 | if (amount == 0) return "0"; 8 | final number = amount.toStringAsFixed(fractionDigits); 9 | if (number.contains('.')) { 10 | int index = number.length - 1; 11 | while (index >= 0 && number[index] == '0') { 12 | index--; 13 | if (index >= 0 && number[index] == '.') { 14 | index--; 15 | break; 16 | } 17 | } 18 | return number.substring(0, index + 1); 19 | } 20 | return number; 21 | } 22 | 23 | final _trailingIntRe = RegExp(r"(.*\s+)(\d+)$"); 24 | 25 | String getDuplicateFileName(String origin, {List? all}) { 26 | assert(all == null || all.contains(origin)); 27 | final (name: originName, number: originNumber) = _extractTrailingNumber(origin); 28 | if (originNumber != null && (all == null || all.length <= 1)) { 29 | return "$originName${originNumber + 1}"; 30 | } 31 | if (all == null || all.length <= 1) return "$origin 2"; 32 | final numbers = []; 33 | for (final file in all) { 34 | final (:name, :number) = _extractTrailingNumber(file); 35 | if (number == null) continue; 36 | if (file == origin || (originNumber == null && name == "$originName ") || name == originName) { 37 | numbers.add(number); 38 | } 39 | } 40 | final maxNumber = numbers.maxOrNull; 41 | if (maxNumber == null) { 42 | return "$origin 2"; 43 | } 44 | if (originNumber == null) { 45 | return "$originName ${maxNumber + 1}"; 46 | } else { 47 | return "$originName${maxNumber + 1}"; 48 | } 49 | } 50 | 51 | ({String name, int? number}) _extractTrailingNumber(String s) { 52 | final matched = _trailingIntRe.firstMatch(s); 53 | if (matched == null) return (name: s, number: null); 54 | final prefix = matched.group(1); 55 | final numberRaw = matched.group(2); 56 | if (prefix == null || numberRaw == null) return (name: "", number: null); 57 | final number = int.tryParse(numberRaw, radix: 10); 58 | if (number == null) return (name: prefix, number: null); 59 | return (name: prefix, number: number); 60 | } 61 | 62 | String allocValidFileName(String name, {List? all}) { 63 | if (all == null || all.isEmpty) return name; 64 | if (!all.contains(name)) return name; 65 | return getDuplicateFileName(name, all: all); 66 | } 67 | -------------------------------------------------------------------------------- /lib/utils/guard_launch.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:flutter/widgets.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:mimir/utils/error.dart'; 5 | import 'package:universal_platform/universal_platform.dart'; 6 | import 'package:url_launcher/url_launcher.dart'; 7 | 8 | Future guardLaunchUrl(BuildContext ctx, Uri url) async { 9 | if (url.scheme == "http" || url.scheme == "https") { 10 | try { 11 | // guards the http(s) 12 | if (kIsWeb || UniversalPlatform.isDesktop) { 13 | return await launchUrl(url, mode: LaunchMode.externalApplication); 14 | } 15 | final target = Uri( 16 | path: "/webview", 17 | queryParameters: {"url": url.toString()}, 18 | ).toString(); 19 | ctx.push(target); 20 | return true; 21 | } catch (error, stackTrace) { 22 | debugPrintError(error, stackTrace); 23 | return false; 24 | } 25 | } 26 | // not http(s) 27 | try { 28 | return await launchUrl(url); 29 | } catch (error, stackTrace) { 30 | debugPrintError(error, stackTrace); 31 | return false; 32 | } 33 | } 34 | 35 | Future guardLaunchUrlString(BuildContext ctx, String url) async { 36 | final uri = Uri.tryParse(url); 37 | if (uri == null) return false; 38 | return await guardLaunchUrl(ctx, uri); 39 | } 40 | -------------------------------------------------------------------------------- /lib/utils/json.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | T? decodeJsonObject(dynamic json, T Function(dynamic obj) transform) { 6 | if (json == null) return null; 7 | try { 8 | if (json is String) { 9 | final obj = jsonDecode(json); 10 | return transform(obj); 11 | } else if (json is Map) { 12 | return transform(json.cast()); 13 | } else { 14 | return transform(json); 15 | } 16 | } catch (_) { 17 | debugPrint("Failed to decode $json"); 18 | return null; 19 | } 20 | } 21 | 22 | String? encodeJsonObject(T? obj, [dynamic Function(T obj)? transform]) { 23 | if (obj == null) return null; 24 | try { 25 | final json = transform != null ? transform(obj) : (obj as dynamic).toJson(); 26 | return jsonEncode(json); 27 | } catch (_) { 28 | debugPrint("Failed to encode $json"); 29 | return null; 30 | } 31 | } 32 | 33 | List? decodeJsonList(dynamic json, T Function(dynamic element) transform) { 34 | if (json == null) return null; 35 | try { 36 | if (json is String) { 37 | final list = jsonDecode(json) as List; 38 | return list.map(transform).toList(); 39 | } else { 40 | final list = jsonDecode(json) as List; 41 | return list.map(transform).toList(); 42 | } 43 | } catch (_) { 44 | debugPrint("Failed to decode $json"); 45 | return null; 46 | } 47 | } 48 | 49 | String? encodeJsonList(List? list, [dynamic Function(T element)? transform]) { 50 | if (list == null) return null; 51 | try { 52 | final json = list.map(transform ?? (e) => (e as dynamic).toJson()).toList(); 53 | return jsonEncode(json); 54 | } catch (_) { 55 | debugPrint("Failed to encode $json"); 56 | return null; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/utils/riverpod.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | 4 | extension BuildContextRiverpodX on BuildContext { 5 | ProviderContainer riverpod({ 6 | bool listen = true, 7 | }) => 8 | ProviderScope.containerOf( 9 | this, 10 | listen: listen, 11 | ); 12 | } 13 | 14 | class ListenableStateNotifier extends StateNotifier { 15 | final Listenable listenable; 16 | final T Function() get; 17 | final void Function(T v)? set; 18 | 19 | ListenableStateNotifier(super._state, this.listenable, this.get, this.set) { 20 | listenable.addListener(_refresh); 21 | } 22 | 23 | void _refresh() { 24 | state = get(); 25 | } 26 | 27 | @override 28 | void dispose() { 29 | listenable.removeListener(_refresh); 30 | super.dispose(); 31 | } 32 | } 33 | 34 | extension ListenableRiverpodX on Listenable { 35 | StateNotifierProvider, T> provider({ 36 | required T Function() get, 37 | void Function(T v)? set, 38 | }) { 39 | return StateNotifierProvider, T>((ref) { 40 | return ListenableStateNotifier( 41 | get(), 42 | this, 43 | get, 44 | set, 45 | ); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/utils/scroll_detector.dart: -------------------------------------------------------------------------------- 1 | enum DetectedScrollState { 2 | down, 3 | up, 4 | idle, 5 | } 6 | 7 | class ScrollDetector { 8 | final int scrollThreshold; 9 | 10 | final int timeDeltaThreshold; 11 | 12 | ScrollDetector({ 13 | this.scrollThreshold = 5, 14 | this.timeDeltaThreshold = 500, 15 | }); 16 | 17 | DateTime? _lastScrollTime; 18 | int? _lastScrollPosition; 19 | 20 | DetectedScrollState update(DateTime time, int y) { 21 | if (_lastScrollTime != null && _lastScrollPosition != null) { 22 | final direction = y - _lastScrollPosition!; 23 | final timeDelta = time.millisecondsSinceEpoch - _lastScrollTime!.millisecondsSinceEpoch; 24 | 25 | // Adjust the threshold and time delta as needed 26 | const scrollThreshold = 5; 27 | const timeDeltaThreshold = 500; 28 | 29 | if (direction.abs() > scrollThreshold && timeDelta < timeDeltaThreshold) { 30 | if (direction > 0) { 31 | return DetectedScrollState.down; 32 | } else { 33 | return DetectedScrollState.up; 34 | } 35 | } 36 | } 37 | 38 | _lastScrollTime = time; 39 | _lastScrollPosition = y; 40 | 41 | return DetectedScrollState.idle; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/utils/state_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | 4 | class StateNotifierBuilder extends StatefulWidget { 5 | final StateNotifier notifier; 6 | final ValueWidgetBuilder builder; 7 | final Widget? child; 8 | 9 | const StateNotifierBuilder({ 10 | super.key, 11 | required this.notifier, 12 | this.child, 13 | required this.builder, 14 | }); 15 | 16 | @override 17 | State> createState() => _StateNotifierBuilderState(); 18 | } 19 | 20 | class _StateNotifierBuilderState extends State> { 21 | late RemoveListener removeListener; 22 | 23 | @override 24 | void initState() { 25 | super.initState(); 26 | removeListener = widget.notifier.addListener(rebuild); 27 | } 28 | 29 | @override 30 | void dispose() { 31 | removeListener(); 32 | super.dispose(); 33 | } 34 | 35 | @override 36 | void didUpdateWidget(covariant StateNotifierBuilder oldWidget) { 37 | super.didUpdateWidget(oldWidget); 38 | if (oldWidget.notifier != widget.notifier) { 39 | removeListener(); 40 | removeListener = widget.notifier.addListener(rebuild); 41 | } 42 | } 43 | 44 | void rebuild(T state) { 45 | setState(() {}); 46 | } 47 | 48 | @override 49 | Widget build(BuildContext context) { 50 | return widget.builder( 51 | context, 52 | widget.notifier.state, 53 | widget.child, 54 | ); 55 | } 56 | } 57 | 58 | extension RettulfValueListenableX on StateNotifier { 59 | /// see [StateNotifierBuilder] 60 | StateNotifierBuilder operator >>( 61 | Widget Function(BuildContext context, T value) builder, 62 | ) => 63 | StateNotifierBuilder( 64 | notifier: this, 65 | builder: (context, value, child) => builder(context, value), 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /lib/utils/strings.dart: -------------------------------------------------------------------------------- 1 | extension StringEx on String { 2 | String removeSuffix(String suffix) => endsWith(suffix) ? substring(0, length - suffix.length) : this; 3 | 4 | String removePrefix(String prefix) => startsWith(prefix) ? substring(prefix.length) : this; 5 | } 6 | -------------------------------------------------------------------------------- /lib/utils/tel.dart: -------------------------------------------------------------------------------- 1 | final RegExp _fixedLineRegex = RegExp(r"(6087\d{4})"); 2 | final RegExp _mobileRegex = RegExp(r"(\d{12})"); 3 | 4 | String? tryParsePhoneNumber(String tel) { 5 | for (final fixedLined in _fixedLineRegex.allMatches(tel)) { 6 | final num = fixedLined.group(0).toString(); 7 | return "021$num"; 8 | } 9 | for (final mobile in _mobileRegex.allMatches(tel)) { 10 | final num = mobile.group(0).toString(); 11 | return num; 12 | } 13 | return null; 14 | } 15 | 16 | String linkifyPhoneNumbers(String content) { 17 | for (final phone in _fixedLineRegex.allMatches(content)) { 18 | final num = phone.group(0).toString(); 19 | content = content.replaceAll(num, '$num'); 20 | } 21 | for (final mobile in _mobileRegex.allMatches(content)) { 22 | final num = mobile.group(0).toString(); 23 | content = content.replaceAll(num, '$num'); 24 | } 25 | return content; 26 | } 27 | -------------------------------------------------------------------------------- /lib/widget/captcha_box.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:easy_localization/easy_localization.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:mimir/design/adaptive/foundation.dart'; 6 | import 'package:mimir/l10n/common.dart'; 7 | import 'package:rettulf/rettulf.dart'; 8 | 9 | class CaptchaDialog extends StatefulWidget { 10 | final Uint8List captchaData; 11 | 12 | const CaptchaDialog({ 13 | super.key, 14 | required this.captchaData, 15 | }); 16 | 17 | @override 18 | State createState() => _CaptchaDialogState(); 19 | } 20 | 21 | class _CaptchaDialogState extends State { 22 | final $captcha = TextEditingController(); 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | return $Dialog$( 27 | title: _i18n.title, 28 | primary: $Action$( 29 | text: _i18n.submit, 30 | warning: true, 31 | isDefault: true, 32 | onPressed: () { 33 | context.navigator.pop($captcha.text); 34 | }, 35 | ), 36 | secondary: $Action$( 37 | text: _i18n.cancel, 38 | onPressed: () { 39 | context.navigator.pop(null); 40 | }, 41 | ), 42 | desc: (ctx) => [ 43 | Image.memory( 44 | widget.captchaData, 45 | scale: 0.5, 46 | ), 47 | $TextField$( 48 | controller: $captcha, 49 | autofocus: true, 50 | placeholder: _i18n.enterHint, 51 | keyboardType: TextInputType.text, 52 | autofillHints: const [AutofillHints.oneTimeCode], 53 | onSubmit: (value) { 54 | context.navigator.pop(value); 55 | }, 56 | ).padOnly(t: 15), 57 | ].column(mas: MainAxisSize.min).padAll(5), 58 | ); 59 | } 60 | 61 | @override 62 | void dispose() { 63 | super.dispose(); 64 | $captcha.dispose(); 65 | } 66 | } 67 | 68 | const _i18n = CaptchaI18n(); 69 | 70 | class CaptchaI18n with CommonI18nMixin { 71 | static const ns = "captcha"; 72 | 73 | const CaptchaI18n(); 74 | 75 | String get title => "$ns.title".tr(); 76 | 77 | String get enterHint => "$ns.enterHint".tr(); 78 | 79 | String get emptyInputError => "$ns.emptyInputError".tr(); 80 | } 81 | -------------------------------------------------------------------------------- /lib/widget/markdown.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:markdown/markdown.dart'; 3 | 4 | import 'html.dart'; 5 | 6 | class FeaturedMarkdownWidget extends StatefulWidget { 7 | final String data; 8 | final bool async; 9 | final Uri? baseUri; 10 | final bool enableGoRoute; 11 | 12 | const FeaturedMarkdownWidget({ 13 | super.key, 14 | required this.data, 15 | this.async = false, 16 | this.baseUri, 17 | this.enableGoRoute = true, 18 | }); 19 | 20 | @override 21 | State createState() => _FeaturedMarkdownWidgetState(); 22 | } 23 | 24 | class _FeaturedMarkdownWidgetState extends State with AutomaticKeepAliveClientMixin { 25 | late String html; 26 | 27 | @override 28 | void initState() { 29 | super.initState(); 30 | html = buildHtml(); 31 | } 32 | 33 | @override 34 | void didUpdateWidget(FeaturedMarkdownWidget oldWidget) { 35 | super.didUpdateWidget(oldWidget); 36 | if (oldWidget.data != widget.data) { 37 | setState(() { 38 | html = buildHtml(); 39 | }); 40 | } 41 | } 42 | 43 | String buildHtml() { 44 | return markdownToHtml( 45 | widget.data, 46 | extensionSet: ExtensionSet.gitHubFlavored, 47 | inlineSyntaxes: [ 48 | LineBreakSyntax(), 49 | AutolinkSyntax(), 50 | EmailAutolinkSyntax(), 51 | ImageSyntax(), 52 | EscapeSyntax(), 53 | EmphasisSyntax.asterisk(), 54 | EmphasisSyntax.underscore(), 55 | ], 56 | blockSyntaxes: const [ 57 | OrderedListSyntax(), 58 | UnorderedListSyntax(), 59 | ], 60 | ); 61 | } 62 | 63 | @override 64 | bool get wantKeepAlive => true; 65 | 66 | @override 67 | Widget build(BuildContext context) { 68 | super.build(context); 69 | return RestyledHtmlWidget( 70 | html, 71 | async: widget.async, 72 | keepOriginalFontSize: true, 73 | baseUri: widget.baseUri, 74 | enableGoRoute: widget.enableGoRoute, 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/widget/not_found.dart: -------------------------------------------------------------------------------- 1 | import 'package:easy_localization/easy_localization.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:rettulf/rettulf.dart'; 4 | import 'package:mimir/design/widget/common.dart'; 5 | 6 | class NotFoundPage extends StatelessWidget { 7 | final String routeName; 8 | 9 | const NotFoundPage(this.routeName, {super.key}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Scaffold( 14 | appBar: AppBar( 15 | title: _i18n.title.text(), 16 | ), 17 | body: LeavingBlank( 18 | icon: Icons.browser_not_supported, 19 | desc: _i18n.subtitle, 20 | ), 21 | ); 22 | } 23 | } 24 | 25 | const _i18n = _I18n(); 26 | 27 | class _I18n { 28 | const _I18n(); 29 | 30 | static const ns = "404"; 31 | 32 | String get title => "$ns.title".tr(); 33 | 34 | String get subtitle => "$ns.subtitle".tr(); 35 | } 36 | --------------------------------------------------------------------------------