commit fb2603c6f0f069a2c6917c19477deee9ff2ce345 Author: DATAEXPRESS\4734 Date: Mon Dec 29 15:20:40 2025 +0800 2025-12-29 First Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..51dc0d7 --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "f5a8537f90d143abd5bb2f658fa69c388da9677b" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: android + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + - platform: web + create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e6dc52 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# learn + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..f43e6a3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.learn" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.learn" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e7b19d9 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/learn/MainActivity.kt b/android/app/src/main/kotlin/com/example/learn/MainActivity.kt new file mode 100644 index 0000000..d2aeaa7 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/learn/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.learn + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..5813224 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + api.gex.com.tw + + + + + \ No newline at end of file diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/assets/F16-01.jpg b/assets/F16-01.jpg new file mode 100644 index 0000000..81f1cf9 Binary files /dev/null and b/assets/F16-01.jpg differ diff --git a/assets/F16-02.jpg b/assets/F16-02.jpg new file mode 100644 index 0000000..07c40fc Binary files /dev/null and b/assets/F16-02.jpg differ diff --git a/assets/F16-03.jpg b/assets/F16-03.jpg new file mode 100644 index 0000000..6e7f692 Binary files /dev/null and b/assets/F16-03.jpg differ diff --git a/assets/F16-04.jpg b/assets/F16-04.jpg new file mode 100644 index 0000000..96aef96 Binary files /dev/null and b/assets/F16-04.jpg differ diff --git a/assets/Su57-01.jpg b/assets/Su57-01.jpg new file mode 100644 index 0000000..8565e44 Binary files /dev/null and b/assets/Su57-01.jpg differ diff --git a/assets/Su57-02.jpg b/assets/Su57-02.jpg new file mode 100644 index 0000000..7266643 Binary files /dev/null and b/assets/Su57-02.jpg differ diff --git a/assets/Su57-03.jpg b/assets/Su57-03.jpg new file mode 100644 index 0000000..e96d78c Binary files /dev/null and b/assets/Su57-03.jpg differ diff --git a/assets/Su57-04.jpg b/assets/Su57-04.jpg new file mode 100644 index 0000000..f6208b7 Binary files /dev/null and b/assets/Su57-04.jpg differ diff --git a/assets/Su57-05.jpg b/assets/Su57-05.jpg new file mode 100644 index 0000000..422753f Binary files /dev/null and b/assets/Su57-05.jpg differ diff --git a/assets/Su57-06.jpg b/assets/Su57-06.jpg new file mode 100644 index 0000000..345eb97 Binary files /dev/null and b/assets/Su57-06.jpg differ diff --git a/lib/AnnouncementManager/announcement_api.dart b/lib/AnnouncementManager/announcement_api.dart new file mode 100644 index 0000000..ba380fc --- /dev/null +++ b/lib/AnnouncementManager/announcement_api.dart @@ -0,0 +1,18 @@ +import './announcement_model.dart'; +// 引入剛剛建立的共用服務 (請依實際路徑調整) +import '../services/generic_api_service.dart'; + +class AnnouncementApiService { + // 實例化共用服務 + final GenericApiService _apiService = GenericApiService(); + + Future> fetchAnnouncements() async { + return await _apiService.fetchList( + tableName: "eipbbs_m", + pk: "uniqueno", + queryFilter: "1^10^uniqueno^*^^pmsm02^^", + // 將 Announcement 的轉換方法傳進去 + fromJson: (json) => Announcement.fromJson(json), + ); + } +} \ No newline at end of file diff --git a/lib/AnnouncementManager/announcement_api_bp.dart b/lib/AnnouncementManager/announcement_api_bp.dart new file mode 100644 index 0000000..6be76a2 --- /dev/null +++ b/lib/AnnouncementManager/announcement_api_bp.dart @@ -0,0 +1,81 @@ +import 'package:http/http.dart' as http; +import 'dart:convert'; +import './announcement_model.dart'; +import '../auth_manager.dart'; // 確保引入 AuthManager + +// API 相關常數 (假設與登入 API 使用相同的 Base URL 結構) +const String BASE_IP = "https://api.gex.com.tw:8033"; +// **請替換成您實際獲取公告列表的 API 路徑** +const String ANNOUNCEMENT_LIST_ENDPOINT = "xapi/v2/eis_demo/orm_api/3/"; +const String ANNOUNCEMENT_LIST_URL = "$BASE_IP/$ANNOUNCEMENT_LIST_ENDPOINT"; + +class AnnouncementApiService { + Future> fetchAnnouncements() async { + // 1. 讀取儲存的 Token + String? token = await AuthManager.getToken(); + + if (token == null) { + print("錯誤:未找到登入 Token,請重新登入!"); + // 這裡通常會導航回登入頁面 + // return; + } + + // 建立 API 參數 (假設獲取公告列表需要類似的 token/身份驗證參數) + final Map params = { + "token": '$token', + //"page": "${page}", + //"perPage": "${perPage}", + //"keywords": "${keywords}", + "_\$_tableName": "eipbbs_m", + "_\$_pk": "uniqueno", + "_\$_action": "P", + "_\$_query_filter": "1^10^uniqueno^*^^pmsm02^^" + }; + + try { + final response = await http.post( + Uri.parse(ANNOUNCEMENT_LIST_URL), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params, + ); + + // 檢查狀態碼 + if (response.statusCode == 200) { + // 解析 API 回應 + final Map responseData = json.decode(response.body); + + // 1. 檢查 code 是否為 0 + if (responseData['code'] == 0) { + + // 2. 根據截圖,'data' 是一個 Map,裡面包著 'items' + final dynamic dataObject = responseData['data']; + + // 檢查 dataObject 是否為 Map,且裡面是否有 'items' 並且是 List + if (dataObject is Map && dataObject['items'] is List) { + + // 3. 取得真正的列表 'items' + List listJson = dataObject['items']; + + // 將 JSON 列表轉換為 Announcement 物件列表 + return listJson.map((json) => Announcement.fromJson(json)).toList(); + } else { + // 雖然 code=0,但資料結構不符合預期 (例如 data 是 null 或沒有 items) + return []; // 或拋出異常,視您的需求而定 + } + } else { + // API 邏輯失敗 (例如 code 不為 0) + throw Exception(responseData['msg'] ?? 'API 呼叫失敗,但連線成功。'); + } + } else { + // HTTP 錯誤 + throw Exception('HTTP 錯誤: ${response.statusCode}'); + } + } catch (e) { + // 網路或解析錯誤 + print('公告獲取錯誤: $e'); + throw Exception('無法載入公告列表,請檢查網路。'); + } + } +} \ No newline at end of file diff --git a/lib/AnnouncementManager/announcement_detail.dart b/lib/AnnouncementManager/announcement_detail.dart new file mode 100644 index 0000000..ed17d3d --- /dev/null +++ b/lib/AnnouncementManager/announcement_detail.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import './announcement_model.dart'; +// 引入 url_launcher 來處理附件 (可選,需要 pubspec.yaml 增加 url_launcher 套件) +// import 'package:url_launcher/url_launcher.dart'; + +class AnnouncementDetail extends StatelessWidget { + final Announcement announcement; + + const AnnouncementDetail({required this.announcement, super.key}); + + // 處理附件下載的函數 (需要 url_launcher 套件) + /* + void _launchAttachment(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + // 處理錯誤 + print('無法開啟附件連結: $url'); + } + } + */ + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(announcement.title, overflow: TextOverflow.ellipsis), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 標題 + Text( + announcement.title, + style: const TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + // 資訊列 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('發布日期: ${announcement.formattedBillDate}', + style: const TextStyle(color: Colors.grey, fontSize: 14)), + Text('作者: ${announcement.createdBy}', + style: const TextStyle(color: Colors.grey, fontSize: 14)), + ], + ), + const Divider(height: 32.0), + + // 附件按鈕 (如果有) + if (announcement.attachment != null && announcement.attachment!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: TextButton.icon( + icon: const Icon(Icons.attach_file), + label: const Text('查看附件'), + onPressed: () { + // TODO: 實作附件下載/開啟邏輯 + // _launchAttachment(announcement.attachment!); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('附件功能待實作。')), + ); + }, + ), + ), + + // 公告詳細內容 (使用 Text 顯示) + Text( + announcement.description, + style: const TextStyle( + fontSize: 16.0, + height: 1.5, + ), + textAlign: TextAlign.justify, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/AnnouncementManager/announcement_manager.dart b/lib/AnnouncementManager/announcement_manager.dart new file mode 100644 index 0000000..0125f90 --- /dev/null +++ b/lib/AnnouncementManager/announcement_manager.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import './announcement_api.dart'; +import './announcement_model.dart'; +import './announcement_detail.dart'; // 稍後創建 + +class AnnouncementManager extends StatefulWidget { + const AnnouncementManager({super.key}); + + @override + State createState() { + return _AnnouncementManagerState(); + } +} + +class _AnnouncementManagerState extends State { + final AnnouncementApiService _apiService = AnnouncementApiService(); + late Future> _announcementsFuture; + + @override + void initState() { + super.initState(); + // 頁面加載時自動開始獲取資料 + _announcementsFuture = _apiService.fetchAnnouncements(); + } + + // 刷新資料的函數 + void _refreshAnnouncements() { + setState(() { + _announcementsFuture = _apiService.fetchAnnouncements(); + }); + } + + // 導航回主頁(與您 NewsManager 中的邏輯相似) + void _backToHome() { + // 假設 MainMenu 在 main.dart 中 + // import './main.dart'; + // Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (context) => const MainMenu()), (route) => false,); + // 在這裡我們假設它作為子頁面,使用 pop 即可返回 + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('企業公告'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: '刷新列表', + onPressed: _refreshAnnouncements, + ), + IconButton( + icon: const Icon(Icons.home), + tooltip: '返回主頁', + onPressed: _backToHome, + ), + ], + ), + body: FutureBuilder>( + future: _announcementsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + // 資料載入中 + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + // 資料載入失敗 (顯示錯誤訊息) + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton(onPressed: _refreshAnnouncements, child: const Text('重試')), + ], + ), + ); + } else if (snapshot.hasData && snapshot.data!.isNotEmpty) { + // 資料載入成功 (顯示列表) + return AnnouncementList(announcements: snapshot.data!); + } else { + // 沒有資料 + return const Center(child: Text('目前沒有企業公告。')); + } + }, + ), + ); + } +} + +// ----------------------------------------------------------- +// 列表顯示小部件 (取代原有的 News 類別) +// ----------------------------------------------------------- + +class AnnouncementList extends StatelessWidget { + final List announcements; + + const AnnouncementList({required this.announcements, super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: announcements.length, + itemBuilder: (BuildContext context, int index) { + final item = announcements[index]; + + return Card( + elevation: 4, + margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: InkWell( + onTap: () { + // 點擊項目:導航到詳細頁面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AnnouncementDetail(announcement: item), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 公告標題 + Text( + item.title, + style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + // 發布日期和作者 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.calendar_today, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + item.formattedBillDate, + style: const TextStyle(fontSize: 14.0, color: Colors.grey), + ), + ], + ), + Row( + children: [ + const Icon(Icons.person, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + item.createdBy, + style: const TextStyle(fontSize: 14.0, color: Colors.grey), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/AnnouncementManager/announcement_model.dart b/lib/AnnouncementManager/announcement_model.dart new file mode 100644 index 0000000..ab723b1 --- /dev/null +++ b/lib/AnnouncementManager/announcement_model.dart @@ -0,0 +1,51 @@ +import 'package:intl/intl.dart'; + +class Announcement { + final int uniqueNo; + final String title; // bbs_title + final String description; // bbs_desc (詳細內容) + final String createdBy; // create_user (作者) + final DateTime billDate; // billdate (發布日期) + final DateTime startDate; // startdate + final DateTime endDate; // end_date + final String? attachment; // bbs_attach + + Announcement({ + required this.uniqueNo, + required this.title, + required this.description, + required this.createdBy, + required this.billDate, + required this.startDate, + required this.endDate, + this.attachment, + }); + + // Factory 構造函數:從 API 返回的 JSON (Map) 創建 Announcement 物件 + factory Announcement.fromJson(Map json) { + // 輔助函數:安全解析日期字串 + DateTime? parseDate(dynamic date) { + if (date is String && date.isNotEmpty) { + // 假設日期格式為 YYYY-MM-DD HH:mm:ss.sss + return DateTime.tryParse(date); + } + return DateTime.now(); // 預設為當前時間或您可以選擇 null + } + + return Announcement( + uniqueNo: json['uniqueno'] as int? ?? 0, + title: json['bbs_title'] as String? ?? '無標題', + description: json['bbs_desc'] as String? ?? '無詳細內容', + createdBy: json['create_user'] as String? ?? '未知作者', + billDate: parseDate(json['billdate'])!, + startDate: parseDate(json['startdate'])!, + endDate: parseDate(json['end_date'])!, + attachment: json['bbs_attach'] as String?, + ); + } + + // 格式化日期,用於列表顯示 + String get formattedBillDate { + return DateFormat('yyyy/MM/dd HH:mm').format(billDate); + } +} \ No newline at end of file diff --git a/lib/News/news.dart b/lib/News/news.dart new file mode 100644 index 0000000..f20e754 --- /dev/null +++ b/lib/News/news.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +// 1. 確保引入 news_detail.dart +import '../News/news_detail.dart'; + +class News extends StatelessWidget { + final List news; + + // 構造函數保持不變 + const News(this.news, {super.key}); + + @override + Widget build(BuildContext context) { + // 使用 ListView.builder 替代 Column 和 map().toList() + // 這樣可以確保內容可以捲動,並且只構建可見的項目,效率更高。 + return ListView.builder( + // 設置列表的長度,即 news 列表的項目數 + itemCount: news.length, + + // 根據索引來構建每個列表項目 + itemBuilder: (BuildContext context, int index) { + final String currentNewsTitle = news[index]; // 提取當前新聞標題 + + // 使用 news[index] 來取得對應的新聞文本 + return Card( + elevation: 5, // 可選: 增加卡片陰影 + margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), // 增加邊距 + // 在這裡可以選擇添加 Key + key: ValueKey(currentNewsTitle + index.toString()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, // 讓圖片和文字可以寬度延伸 + children: [ + // --- 點擊功能的修改在這裡 --- + GestureDetector( + onTap: () { + // 點擊事件:導航到 NewsDetail 畫面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NewsDetail( + // 2. 將當前新聞的標題/內容傳遞給詳細頁面 + newsTitle: currentNewsTitle, + ), + ), + ); + }, + child: Image.asset( + 'assets/F16-02.jpg', + fit: BoxFit.cover, + height: 200, + ), + ), + // -------------------------- + Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + currentNewsTitle, + style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/News/news_detail.dart b/lib/News/news_detail.dart new file mode 100644 index 0000000..56d768c --- /dev/null +++ b/lib/News/news_detail.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +// 這是一個 StatelessWidget,用於顯示單條新聞的詳細內容 +class NewsDetail extends StatelessWidget { + final String newsTitle; // 接收從前一個畫面傳遞過來的新聞標題 + + // 為了演示,我們假設詳細內容是一個較長的文本 + final String newsDetailContent = + "這是新聞的詳細內容。它包含了更深入的分析、更多的數據和完整的背景資訊。點擊圖片後,使用者可以在這個頁面仔細閱讀。由於這是一個範例,我們使用了一些重複的文本來模擬文章長度,以確保頁面可以滾動。在這個頁面,您可以加入更多的元素,例如不同的圖片、作者資訊、發布日期等。"; + + const NewsDetail({required this.newsTitle, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + // 設置 App Bar,標題可以是傳入的新聞標題 + appBar: AppBar( + title: Text(newsTitle, overflow: TextOverflow.ellipsis), // 防止標題過長 + ), + // 使用 SingleChildScrollView 確保頁面內容超出螢幕時可以滾動 + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 再次顯示圖片 (可選) + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.asset( + 'assets/F16-02.jpg', + fit: BoxFit.cover, + height: 250, + width: double.infinity, // 佔滿寬度 + ), + ), + const SizedBox(height: 16.0), + + // 顯示新聞標題 + Text( + newsTitle, + style: const TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.w800, + ), + ), + const Divider(height: 32.0), // 分隔線 + + // 顯示新聞詳細內容 + Text( + newsDetailContent * 5, // 重複內容 5 次來模擬長文章 + style: const TextStyle( + fontSize: 16.0, + height: 1.5, // 增加行高,改善閱讀體驗 + ), + textAlign: TextAlign.justify, // 內容兩端對齊 + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/News/news_manager.dart b/lib/News/news_manager.dart new file mode 100644 index 0000000..44b5ecf --- /dev/null +++ b/lib/News/news_manager.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +// 確保你的 news.dart 文件和 News 類已經被正確引入 +import 'news.dart'; +// 確保引入 MainMenu 所在的 main.dart 文件,以便導航 +import '../main.dart'; + +class NewsManager extends StatefulWidget { + const NewsManager({super.key}); + + @override + State createState() { + return _NewsManagerState(); + } +} + +class _NewsManagerState extends State { + List news = ['第一筆資料: F-16 戰機升級計畫', '第二筆資料: 台積電研發新突破']; + + void _addNews() { + setState(() { + news.add('新增資料:最新動態 ${DateTime.now().second}'); + }); + } + + // 新增的導航函數:返回到主選單 + void _backToHome() { + // Navigator.popUntil 會一直彈出 (Pop) 路由堆棧中的頁面,直到遇到一個 + // 滿足條件 (predicte) 的路由。在這裡,我們使用 (route) => route.isFirst + // 來返回到路由堆棧中的第一個頁面,也就是 MainMenu (如果您的啟動順序是 Login -> MainMenu)。 + // 如果 MainMenu 不是第一個頁面,您可以考慮使用: + // Navigator.popUntil(context, ModalRoute.withName('/main_menu')); + // 但這需要您在 MaterialApp 中為 MainMenu 設定路由名稱。 + + // 最保險且簡單的方法是直接彈出所有,然後重新推入 MainMenu(確保 MainMenu 不會有歷史記錄) + // 或者我們假設 MainMenu 是 Login 成功後的第一個頁面,使用 pushReplacement 清理過的堆棧。 + // 在本例中,我們直接導航回 MainMenu,並清除所有在它之上的頁面。 + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const MainMenu()), + (Route route) => false, // 移除所有前面的路由 + ); + + // 註:若 NewsManager 已經是 Scaffold 的子頁面,使用 Navigator.pop(context) + // 即可返回上一個頁面(即 MainMenu)。但如果想確保是回主頁,用 pushAndRemoveUntil 更明確。 + } + + @override + Widget build(BuildContext context) { + // *** 關鍵修改:將整個頁面包裹在 Scaffold 中以包含 AppBar *** + return Scaffold( + appBar: AppBar( + title: const Text('公告事項'), + // ---------------------------------------------------- + // 新增的返回主頁按鈕 + actions: [ + IconButton( + icon: const Icon(Icons.home), // 使用一個 Home 圖標 + tooltip: '返回主頁', // 長按時的提示 + onPressed: _backToHome, // 點擊時呼叫導航函數 + ), + ], + // ---------------------------------------------------- + ), + body: Column( + children: [ + // 1. 固定高度的按鈕部分 + Container( + margin: const EdgeInsets.all(10), + child: ElevatedButton( + onPressed: _addNews, + child: const Text( + '新增資料', + style: TextStyle(fontSize: 20, color: Colors.redAccent), + ), + ), + ), + + // 2. 佔據剩餘空間的列表部分 + Expanded( + child: News(news), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/auth_manager.dart b/lib/auth_manager.dart new file mode 100644 index 0000000..fa0b9d5 --- /dev/null +++ b/lib/auth_manager.dart @@ -0,0 +1,45 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class AuthManager { + // 定義鍵值常數,避免硬編碼字串出錯 + static const String _tokenKey = 'auth_token'; + static const String _userIdKey = 'user_id'; // [新增] 用於儲存工號的鍵值 + + /// 儲存登入資訊:包含 Token 與工號 + /// 當登入成功時,同時呼叫此方法 + static Future saveLoginInfo(String token, String userId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); + await prefs.setString(_userIdKey, userId); + print('Login info saved: UserID=$userId'); + } + + /// 讀取儲存的工號 (userid) + /// 如果沒找到則回傳 null,可用於判斷是否需重新登入 + static Future getUserId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_userIdKey); + } + + /// 讀取儲存的 Token + static Future getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_tokenKey); + } + + /// 移除所有登入資訊 (用於登出) + /// 確保 Token 與工號同步清除,保障資安 + static Future logout() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_tokenKey); + await prefs.remove(_userIdKey); + print('User logged out, auth info cleared.'); + } + + /// 檢查是否已登入 (Token 與 UserID 皆存在) + static Future isLoggedIn() async { + final token = await getToken(); + final userId = await getUserId(); + return token != null && userId != null; + } +} \ No newline at end of file diff --git a/lib/calendar/calendar_api.dart b/lib/calendar/calendar_api.dart new file mode 100644 index 0000000..c2ddcf5 --- /dev/null +++ b/lib/calendar/calendar_api.dart @@ -0,0 +1,39 @@ +import './calendar_model.dart'; +import '../services/generic_api_service.dart'; +import 'package:intl/intl.dart'; + +class CalendarApiService { + final GenericApiService _apiService = GenericApiService(); + + // 獲取指定月份或範圍的行程 + Future> fetchEvents(String userId, DateTime month) async { + // 企業實作建議:僅抓取特定月份資料,減少手機負載 + final sDate = DateTime(month.year, month.month, 1); + final eDate = DateTime(month.year, month.month + 1, 0); + + String queryFilter = "1^500^start_time^*^personid^=^$userId"; + + return await _apiService.fetchList( + tableName: "eip_new_calendar", + pk: "uuid", + queryFilter: queryFilter, + fromJson: (json) => CalendarEvent.fromJson(json), + ); + } + + // 新增/更新行程 + Future saveEvent(CalendarEvent event) async { + final data = { + "uuid": event.uuid, + "title": event.title, + "cal_description": event.description, + "start_time": event.startTime?.toIso8601String(), + "end_time": event.endTime?.toIso8601String(), + "personid": event.personId, + "cal_finished": event.isFinished ? 'Y' : 'N', + "update_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), + }; + // return await _apiService.saveData("eip_new_calendar", data); + return true; + } +} \ No newline at end of file diff --git a/lib/calendar/calendar_detail.dart b/lib/calendar/calendar_detail.dart new file mode 100644 index 0000000..79d72b1 --- /dev/null +++ b/lib/calendar/calendar_detail.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import './calendar_model.dart'; + +class CalendarDetail extends StatelessWidget { + final CalendarEvent event; + + const CalendarDetail({required this.event, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('行程詳情')), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + Row( + children: [ + CircleAvatar(backgroundColor: event.levelColor, radius: 8), + const SizedBox(width: 12), + Expanded( + child: Text(event.title, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + ), + ], + ), + const Divider(height: 40), + _buildDetailItem(Icons.access_time, '時間範圍', event.timeRangeText), + _buildDetailItem(Icons.repeat, '重複設定', event.repeatedFlag ?? '無重複'), + _buildDetailItem(Icons.folder_open, '關聯專案', event.projectId ?? '無相關專案'), + _buildDetailItem(Icons.category_outlined, '類型', event.type ?? '一般行程'), + const SizedBox(height: 24), + const Text('行程描述', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)), + child: Text(event.description ?? '無詳細描述內容', style: const TextStyle(height: 1.5)), + ), + ], + ), + ); + } + + Widget _buildDetailItem(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + children: [ + Icon(icon, color: Colors.blueGrey, size: 20), + const SizedBox(width: 15), + Text(label, style: const TextStyle(color: Colors.grey)), + const Spacer(), + Text(value, style: const TextStyle(fontWeight: FontWeight.w500)), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/calendar/calendar_manager.dart b/lib/calendar/calendar_manager.dart new file mode 100644 index 0000000..54a1b6e --- /dev/null +++ b/lib/calendar/calendar_manager.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:table_calendar/table_calendar.dart'; // 建議引入此套件 +import 'package:intl/intl.dart'; +import './calendar_model.dart'; +import './calendar_api.dart'; +import './calendar_detail.dart'; + +class CalendarManager extends StatefulWidget { + final String userId; + const CalendarManager({required this.userId, super.key}); + + @override + State createState() => _CalendarManagerState(); +} + +class _CalendarManagerState extends State { + final CalendarApiService _apiService = CalendarApiService(); + + // 狀態控制 + CalendarFormat _calendarFormat = CalendarFormat.month; + DateTime _focusedDay = DateTime.now(); + DateTime? _selectedDay; + + // 資料儲存:將事件按日期分類,方便日曆標點 + Map> _eventsMap = {}; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _selectedDay = _focusedDay; + _fetchMonthEvents(_focusedDay); + } + + // 核心:抓取整個月的資料並進行分類 + Future _fetchMonthEvents(DateTime month) async { + setState(() => _isLoading = true); + try { + final events = await _apiService.fetchEvents(widget.userId, month); + + // 將 List 轉換為 Map> + Map> newMap = {}; + for (var event in events) { + if (event.startTime != null) { + // 只取日期部分作為 Key + final dateKey = DateTime(event.startTime!.year, event.startTime!.month, event.startTime!.day); + if (newMap[dateKey] == null) newMap[dateKey] = []; + newMap[dateKey]!.add(event); + } + } + + setState(() { + _eventsMap = newMap; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("載入失敗: $e"))); + } + } + + // 獲取選定日期的行程 + List _getEventsForDay(DateTime day) { + final dateKey = DateTime(day.year, day.month, day.day); + return _eventsMap[dateKey] ?? []; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('企業行事曆'), + actions: [ + IconButton(icon: const Icon(Icons.today), onPressed: () => setState(() => _focusedDay = DateTime.now())), + ], + ), + body: Column( + children: [ + // 1. 日曆組件 + TableCalendar( + firstDay: DateTime.utc(2020, 1, 1), + lastDay: DateTime.utc(2030, 12, 31), + focusedDay: _focusedDay, + calendarFormat: _calendarFormat, + selectedDayPredicate: (day) => isSameDay(_selectedDay, day), + eventLoader: _getEventsForDay, // 在有行程的日期顯示小點點 + + // 樣式設定 + calendarStyle: CalendarStyle( + todayDecoration: BoxDecoration(color: Colors.blue.withOpacity(0.5), shape: BoxShape.circle), + selectedDecoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle), + markerDecoration: const BoxDecoration(color: Colors.orange, shape: BoxShape.circle), + ), + + // 互動事件 + onDaySelected: (selectedDay, focusedDay) { + setState(() { + _selectedDay = selectedDay; + _focusedDay = focusedDay; + }); + }, + onFormatChanged: (format) { + setState(() => _calendarFormat = format); + }, + onPageChanged: (focusedDay) { + _focusedDay = focusedDay; + _fetchMonthEvents(focusedDay); // 切換月份時自動重新抓取 API + }, + ), + + const Divider(height: 1), + + // 2. 下方行程列表 + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _buildEventList(), + ), + ], + ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () { /* 實重新增行程邏輯 */ }, + ), + ); + } + + Widget _buildEventList() { + final dayEvents = _getEventsForDay(_selectedDay!); + + if (dayEvents.isEmpty) { + return Center( + child: Text( + "${DateFormat('MM/dd').format(_selectedDay!)} 沒有行程", + style: const TextStyle(color: Colors.grey), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: dayEvents.length, + itemBuilder: (context, index) { + final event = dayEvents[index]; + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + leading: Container( + width: 4, + height: 40, + decoration: BoxDecoration( + color: event.levelColor, + borderRadius: BorderRadius.circular(2), + ), + ), + title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(event.timeRangeText), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => CalendarDetail(event: event)) + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/calendar/calendar_model.dart b/lib/calendar/calendar_model.dart new file mode 100644 index 0000000..05693dc --- /dev/null +++ b/lib/calendar/calendar_model.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class CalendarEvent { + final String uuid; + final String title; + final String? description; + final DateTime? startTime; + final DateTime? endTime; + final String? type; // cal_type + final String? level; // cal_level (重要程度) + final String? personId; + final bool isFinished; // cal_finished: 'Y' or 'N' + final String? repeatedFlag; // cal_repeated_flag + final String? projectId; + + CalendarEvent({ + required this.uuid, + required this.title, + this.description, + this.startTime, + this.endTime, + this.type, + this.level, + this.personId, + this.isFinished = false, + this.repeatedFlag, + this.projectId, + }); + + factory CalendarEvent.fromJson(Map json) { + return CalendarEvent( + uuid: json['uuid'] as String, + title: json['title'] as String? ?? '未命名行程', + description: json['cal_description'] as String?, + startTime: json['start_time'] != null ? DateTime.tryParse(json['start_time']) : null, + endTime: json['end_time'] != null ? DateTime.tryParse(json['end_time']) : null, + type: json['cal_type'] as String?, + level: json['cal_level'] as String?, + personId: json['personid'] as String?, + isFinished: json['cal_finished'] == 'Y', + repeatedFlag: json['cal_repeated_flag'] as String?, + projectId: json['projectid'] as String?, + ); + } + + // UI 輔助屬性 + Color get levelColor { + switch (level?.toUpperCase()) { + case 'URGENT': return Colors.red; + case 'HIGH': return Colors.orange; + case 'NORMAL': return Colors.blue; + default: return Colors.grey; + } + } + + String get timeRangeText { + if (startTime == null) return "未定時"; + final df = DateFormat('HH:mm'); + return "${df.format(startTime!)}${endTime != null ? ' - ${df.format(endTime!)}' : ''}"; + } +} \ No newline at end of file diff --git a/lib/clockin/clock_in_api.dart b/lib/clockin/clock_in_api.dart new file mode 100644 index 0000000..2dbb296 --- /dev/null +++ b/lib/clockin/clock_in_api.dart @@ -0,0 +1,48 @@ +import './clock_in_model.dart'; +import '../services/generic_api_service.dart'; +import 'package:intl/intl.dart'; + +class ClockInApiService { + final GenericApiService _apiService = GenericApiService(); + + // 獲取個人打卡歷史 (預設按時間降冪) + Future> fetchHistory(String userId) async { + String queryFilter = "1^100^ClockInDateTime^*^ClockInUserId^=^$userId"; + + return await _apiService.fetchList( + tableName: "hrs_ClockInRecord", + pk: "ClockInId", + queryFilter: queryFilter, + fromJson: (json) => ClockInRecord.fromJson(json), + ); + } + + // 新增打卡紀錄 (POST) + Future postClockIn(ClockInRecord record) async { + // 假設後端有一個通用保存 API + final data = { + "ClockInUserId": record.userId, + "ClockInDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), + "ClockInLatitude": record.latitude, + "ClockInLongitude": record.longitude, + "ClockInType": record.type, + "ClockInStoreId": record.storeId, + "CreatorId": record.userId, + "CreateDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), + }; + + // 呼叫 API 並回傳結果 + // return await _apiService.saveData("hrs_ClockInRecord", data); + return true; // 模擬成功 + } + + // 在 ClockInApiService 類別中新增: + Future> fetchStores() async { + return await _apiService.fetchList( + tableName: "hrs_ClockInStore", + pk: "StoreId", + queryFilter: "1^100^StoreId^*^^^Stat^Y", // 僅抓取啟用狀態為 Y 的店點 + fromJson: (json) => ClockInStore.fromJson(json), + ); + } +} \ No newline at end of file diff --git a/lib/clockin/clock_in_manager.dart b/lib/clockin/clock_in_manager.dart new file mode 100644 index 0000000..68e4fb6 --- /dev/null +++ b/lib/clockin/clock_in_manager.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'dart:async'; +import './clock_in_model.dart'; +import './clock_in_api.dart'; +import 'package:intl/intl.dart'; + +class ClockInManager extends StatefulWidget { + final String userId; + const ClockInManager({required this.userId, super.key}); + + @override + State createState() => _ClockInManagerState(); +} + +class _ClockInManagerState extends State { + final ClockInApiService _apiService = ClockInApiService(); + late Future> _historyFuture; + + // --- 新增:店點相關變數 --- + List _stores = []; + ClockInStore? _selectedStore; + bool _isLoadingStores = true; + + Position? _currentPosition; + double _distanceInMeters = -1; + String _currentTime = ""; + late Timer _timer; + + @override + void initState() { + super.initState(); + _initData(); + _startClock(); + } + + // 初始化資料:先抓店點,再抓歷史紀錄 + Future _initData() async { + try { + final stores = await _apiService.fetchStores(); + setState(() { + _stores = stores; + if (_stores.isNotEmpty) _selectedStore = _stores.first; + _isLoadingStores = false; + }); + _refreshHistory(); + _getCurrentLocation(); + } catch (e) { + _showMsg("初始化店點失敗: $e"); + } + } + + void _startClock() { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) setState(() => _currentTime = DateFormat('HH:mm:ss').format(DateTime.now())); + }); + } + + // 修改:根據「目前選中店點」計算距離 + Future _getCurrentLocation() async { + if (_selectedStore == null) return; + + final position = await Geolocator.getCurrentPosition(); + + // 使用所選店點的經緯度進行計算 + final distance = Geolocator.distanceBetween( + position.latitude, + position.longitude, + _selectedStore!.latitude, + _selectedStore!.longitude + ); + + if (mounted) { + setState(() { + _currentPosition = position; + _distanceInMeters = distance; + }); + } + } + + void _refreshHistory() { + final future = _apiService.fetchHistory(widget.userId); + setState(() => _historyFuture = future); + } + + // 修改:打卡動作加入 StoreId 與動態距離判斷 + Future _handleClockIn(String type) async { + if (_selectedStore == null) { + _showMsg("錯誤:請先選擇打卡店點"); + return; + } + + await _getCurrentLocation(); + + // 使用資料表中的 Distance 欄位作為判斷標準 + if (_distanceInMeters > _selectedStore!.distance || _distanceInMeters == -1) { + _showMsg("打卡失敗:距離 ${_selectedStore!.storeName} 已超過 ${_selectedStore!.distance} 公尺 (目前: ${_distanceInMeters.toInt()}m)"); + return; + } + + final newRecord = ClockInRecord( + userId: int.tryParse(widget.userId), + type: type, + latitude: _currentPosition?.latitude, + longitude: _currentPosition?.longitude, + storeId: _selectedStore!.storeId, // 使用動態 ID + ); + + bool success = await _apiService.postClockIn(newRecord); + if (success) { + _showMsg("[$type] 打卡成功!地點: ${_selectedStore!.storeName}"); + _refreshHistory(); + } + } + + void _showMsg(String msg) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 動態判斷是否在選中店點的範圍內 + bool isWithinRange = _selectedStore != null && + _distanceInMeters != -1 && + _distanceInMeters <= _selectedStore!.distance; + + return Scaffold( + appBar: AppBar(title: const Text('員工行動打卡')), + body: _isLoadingStores + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + // 新增:店點選擇下拉選單區 + _buildStoreSelector(), + + _buildStatusCard(isWithinRange), + + const Divider(height: 1), + // ... 歷史紀錄標題與清單部分維持不變 (同附件) + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('今日打卡紀錄', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(onPressed: _refreshHistory, icon: const Icon(Icons.refresh)), + ], + ), + ), + Expanded( + child: FutureBuilder>( + future: _historyFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator()); + if (!snapshot.hasData || snapshot.data!.isEmpty) return const Center(child: Text('查無紀錄')); + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (ctx, i) => _buildHistoryItem(snapshot.data![i]), + ); + }, + ), + ), + ], + ), + ); + } + + // 新增:店點選擇器 UI + Widget _buildStoreSelector() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + color: Colors.white, + child: DropdownButtonFormField( + decoration: const InputDecoration( + labelText: "選擇打卡店點", + prefixIcon: Icon(Icons.store), + border: OutlineInputBorder(), + ), + value: _selectedStore, + items: _stores.map((s) => DropdownMenuItem( + value: s, + child: Text(s.storeName), + )).toList(), + onChanged: (val) { + setState(() { + _selectedStore = val; + _distanceInMeters = -1; // 切換時重置距離,等待下次定位 + }); + _getCurrentLocation(); // 切換後立即重新計算距離 + }, + ), + ); + } + + // 修改:狀態卡片顯示 + Widget _buildStatusCard(bool isWithinRange) { + return Container( + padding: const EdgeInsets.all(20), + color: Colors.blue.shade50, + child: Column( + children: [ + Text(_currentTime, style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.blue)), + const SizedBox(height: 10), + if (_selectedStore != null) ...[ + Text(_selectedStore!.storeAddress ?? "", style: const TextStyle(color: Colors.blueGrey)), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.location_on, color: isWithinRange ? Colors.green : Colors.red), + Text( + isWithinRange + ? "已進入 ${_selectedStore!.storeName} 範圍" + : "距離過遠: ${_distanceInMeters.toInt()}m (限制: ${_selectedStore!.distance}m)", + style: TextStyle(color: isWithinRange ? Colors.green : Colors.red, fontWeight: FontWeight.bold), + ), + ], + ), + ], + const SizedBox(height: 20), + Row( + children: [ + Expanded(child: _actionBtn("上班打卡", Colors.blue, () => _handleClockIn("上班"))), + const SizedBox(width: 15), + Expanded(child: _actionBtn("下班打卡", Colors.orange, () => _handleClockIn("下班"))), + ], + ) + ], + ), + ); + } + + // _actionBtn 與 _buildHistoryItem 維持與附件原程式相同... + Widget _actionBtn(String label, Color color, VoidCallback onPressed) { + return ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: color, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15)), + onPressed: onPressed, + child: Text(label, style: const TextStyle(fontSize: 18)), + ); + } + + Widget _buildHistoryItem(ClockInRecord record) { + return ListTile( + leading: CircleAvatar(backgroundColor: record.typeColor, child: Text(record.type?[0] ?? '', style: const TextStyle(color: Colors.white))), + title: Text("${record.type} - ${record.formattedTime}"), + subtitle: Text(record.formattedDate), + trailing: const Icon(Icons.check_circle, color: Colors.green, size: 16), + ); + } +} \ No newline at end of file diff --git a/lib/clockin/clock_in_model.dart b/lib/clockin/clock_in_model.dart new file mode 100644 index 0000000..2a5d5e6 --- /dev/null +++ b/lib/clockin/clock_in_model.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class ClockInRecord { + final int? clockInId; + final int? userId; + final DateTime? dateTime; + final double? latitude; + final double? longitude; + final String? type; // 上班/下班/加班... + final String? storeId; + + ClockInRecord({ + this.clockInId, + this.userId, + this.dateTime, + this.latitude, + this.longitude, + this.type, + this.storeId, + }); + + factory ClockInRecord.fromJson(Map json) { + return ClockInRecord( + clockInId: json['ClockInId'] as int?, + userId: json['ClockInUserId'] as int?, + dateTime: json['ClockInDateTime'] != null ? DateTime.tryParse(json['ClockInDateTime']) : null, + latitude: double.tryParse(json['ClockInLatitude']?.toString() ?? '0'), + longitude: double.tryParse(json['ClockInLongitude']?.toString() ?? '0'), + type: json['ClockInType'] as String?, + storeId: json['ClockInStoreId'] as String?, + ); + } + + // Helper: 格式化顯示時間 + String get formattedTime => dateTime != null ? DateFormat('HH:mm:ss').format(dateTime!) : '--:--'; + String get formattedDate => dateTime != null ? DateFormat('yyyy-MM-dd').format(dateTime!) : 'N/A'; + + // Helper: 根據打卡類型回傳顏色 + Color get typeColor { + if (type == '上班') return Colors.blue; + if (type == '下班') return Colors.orange; + return Colors.grey; + } +} + +class ClockInStore { + final String storeId; + final String storeName; + final String? storeAddress; + final double latitude; + final double longitude; + final int distance; // 允許打卡公尺數 + + ClockInStore({ + required this.storeId, + required this.storeName, + this.storeAddress, + required this.latitude, + required this.longitude, + required this.distance, + }); + + factory ClockInStore.fromJson(Map json) { + return ClockInStore( + storeId: json['StoreId'] as String, + storeName: json['StoreName'] as String? ?? '', + storeAddress: json['StoreAddress'] as String?, + // 強制將 decimal/String 轉為 double + latitude: double.tryParse(json['StoreLatitude'].toString()) ?? 0.0, + longitude: double.tryParse(json['StoreLongitude'].toString()) ?? 0.0, + distance: int.tryParse(json['Distance'].toString()) ?? 100, + ); + } +} \ No newline at end of file diff --git a/lib/employee/person_api.dart b/lib/employee/person_api.dart new file mode 100644 index 0000000..88db90d --- /dev/null +++ b/lib/employee/person_api.dart @@ -0,0 +1,26 @@ +import './person_model.dart'; +import '../services/generic_api_service.dart'; // 假設您有這個共用服務 + +class PersonApiService { + final GenericApiService _apiService = GenericApiService(); + + // 獲取所有人員列表,增加可選的 searchName 參數 + Future> fetchPeople({String? searchName}) async { + // 預設參數:每頁 100 筆,按中文姓名排序 + String filterPart = ""; + + // 如果提供了 searchName,則增加模糊查詢過濾條件 + if (searchName != null && searchName.isNotEmpty) { + filterPart = "personcname^%$searchName%"; + } + + String v_queryFilter = "1^100^personid^*^^^$filterPart"; + + return await _apiService.fetchList( + tableName: "basperson", // 對應到 basperson 表格 + pk: "personid", // 主鍵為 personid + queryFilter: v_queryFilter, + fromJson: (json) => Person.fromJson(json), + ); + } +} \ No newline at end of file diff --git a/lib/employee/person_detail.dart b/lib/employee/person_detail.dart new file mode 100644 index 0000000..6be6589 --- /dev/null +++ b/lib/employee/person_detail.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import './person_model.dart'; +import 'package:url_launcher/url_launcher.dart'; // 用於撥打電話和發送郵件 + +class PersonDetail extends StatelessWidget { + final Person person; + + const PersonDetail({required this.person, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(person.personCName ?? '人員詳細資訊'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 姓名與職稱 + CircleAvatar( + radius: 50, + backgroundColor: person.sexColor.withOpacity(0.2), + child: Icon(person.sexIcon, size: 60, color: person.sexColor), + ), + const SizedBox(height: 10), + Text( + person.personCName ?? 'N/A', + style: const TextStyle(fontSize: 28.0, fontWeight: FontWeight.bold, color: Colors.black87), + ), + Text( + person.jobName ?? 'N/A', + style: const TextStyle(fontSize: 18.0, color: Colors.black54), + ), + const Divider(height: 30.0, thickness: 1), + + // 聯絡資訊列表 + _buildInfoTile( + Icons.badge, + '工號 / ID', + person.personId, + ), + _buildInfoTile( + Icons.business, + '部門代號', + person.departmentId ?? 'N/A', + ), + _buildActionTile( + context, + Icons.phone, + '公司電話', + person.tel ?? 'N/A', + person.tel, + 'tel:', + ), + _buildActionTile( + context, + Icons.smartphone, + '手機號碼', + person.cellphone ?? 'N/A', + person.cellphone, + 'tel:', + ), + _buildActionTile( + context, + Icons.email, + '電子郵件', + person.email ?? 'N/A', + person.email, + 'mailto:', + ), + ], + ), + ), + ); + } + + // 靜態資訊欄位 + Widget _buildInfoTile(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon(icon, color: Colors.grey[700], size: 24), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + Text( + value, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ), + ); + } + + // 可操作(點擊撥號/發送郵件)的欄位 + Widget _buildActionTile( + BuildContext context, IconData icon, String label, String displayValue, + String? actionValue, String protocol) { + final canLaunch = actionValue != null && actionValue.isNotEmpty; + + Future _launchUrl() async { + if (canLaunch) { + final uri = Uri.parse('$protocol$actionValue'); + if (!await launchUrl(uri)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('無法打開 $displayValue')), + ); + } + } + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: InkWell( + onTap: canLaunch ? _launchUrl : null, + child: Row( + children: [ + Icon(icon, color: canLaunch ? Colors.deepPurple : Colors.grey[700], size: 24), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + Text( + displayValue, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: canLaunch ? Colors.deepPurple : Colors.black, + decoration: canLaunch ? TextDecoration.underline : TextDecoration.none, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/employee/person_manager.dart b/lib/employee/person_manager.dart new file mode 100644 index 0000000..90dee3a --- /dev/null +++ b/lib/employee/person_manager.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import './person_api.dart'; +import './person_model.dart'; +import './person_detail.dart'; // 稍後創建 + +class PersonManager extends StatefulWidget { + const PersonManager({super.key}); + + @override + State createState() { + return _PersonManagerState(); + } +} + +class _PersonManagerState extends State { + late PersonApiService _apiService; + late Future> _peopleFuture; + + // 新增:搜尋文字控制器 + final TextEditingController _searchController = TextEditingController(); + + // 新增:用於記錄當前的搜尋關鍵字 + String _currentSearchTerm = ''; + + @override + void initState() { + super.initState(); + _apiService = PersonApiService(); + // 頁面加載時自動開始獲取資料 + _peopleFuture = _apiService.fetchPeople(); + + // 新增:監聽搜尋框文字變更 + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + // 釋放資源 + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); + super.dispose(); + } + + // 搜尋文字變更時觸發的邏輯 + void _onSearchChanged() { + final newTerm = _searchController.text; + // 只有當關鍵字真正改變時才刷新 + if (newTerm != _currentSearchTerm) { + _currentSearchTerm = newTerm; + _refreshPeople(); + } + } + + // 刷新資料的函數 + void _refreshPeople() { + setState(() { + // 調用 API 時傳入目前的搜尋關鍵字 + _peopleFuture = _apiService.fetchPeople(searchName: _currentSearchTerm); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('公司通訊錄'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: '刷新列表', + onPressed: _refreshPeople, + ), + ], + // 新增:底部放置搜尋框 + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60.0), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: '輸入姓名進行查詢...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _currentSearchTerm.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); // 清空文字會觸發 _onSearchChanged + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10.0), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.white, + ), + ), + ), + ), + ), + body: FutureBuilder>( + future: _peopleFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton(onPressed: _refreshPeople, child: const Text('重試')), + ], + ), + ); + } else if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return PersonList(people: snapshot.data!); + } else { + return const Center(child: Text('查無人員資料。')); + } + }, + ), + ); + } +} + +// ----------------------------------------------------------- +// 列表顯示小部件 (PersonList) +// ----------------------------------------------------------- + +class PersonList extends StatelessWidget { + final List people; + + const PersonList({required this.people, super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: people.length, + itemBuilder: (BuildContext context, int index) { + final person = people[index]; + + return Card( + elevation: 3, + margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0), + child: InkWell( + onTap: () { + // 點擊項目:導航到詳細頁面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PersonDetail(person: person), + ), + ); + }, + child: ListTile( + // 左側性別指示器 + leading: Icon( + person.sexIcon, + color: person.sexColor, + size: 32, + ), + title: Text( + person.personCName ?? '姓名未知', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${person.departmentId ?? '無部門'} | ${person.jobName ?? '無職稱'}', + style: const TextStyle(fontSize: 12), + ), + trailing: const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/employee/person_model.dart b/lib/employee/person_model.dart new file mode 100644 index 0000000..2adb81f --- /dev/null +++ b/lib/employee/person_model.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class Person { + final String personId; // personid + final String? personCName; // personcname (中文姓名) + final String? jobName; // jobname (職稱) + final String? departmentId; // departmentid (部門代號) + final String? tel; // tel (公司電話) + final String? cellphone; // cellphone (手機) + final String? email; // email (電子郵件) + final String? sex; // sex (性別) + + Person({ + required this.personId, + this.personCName, + this.jobName, + this.departmentId, + this.tel, + this.cellphone, + this.email, + this.sex, + }); + + // Factory 構造函數:從 API 返回的 JSON (Map) 創建 Person 物件 + factory Person.fromJson(Map json) { + return Person( + personId: json['personid'] as String? ?? 'N/A', // + personCName: json['personcname'] as String?, // + jobName: json['jobname'] as String?, // + departmentId: json['departmentid'] as String?, // + tel: json['tel'] as String?, // + cellphone: json['cellphone'] as String?, // + email: json['email'] as String?, // + sex: json['sex'] as String?, // + ); + } + + // 輔助屬性:獲取性別圖示 + IconData get sexIcon { + switch (sex?.toUpperCase()) { + case 'M': + return Icons.male; + case 'F': + return Icons.female; + default: + return Icons.person; + } + } + + // 輔助屬性:獲取性別顏色 + Color get sexColor { + switch (sex?.toUpperCase()) { + case 'M': + return Colors.blue; + case 'F': + return Colors.pink; + default: + return Colors.grey; + } + } +} \ No newline at end of file diff --git a/lib/historys/chap0215_v1/login_page.dart b/lib/historys/chap0215_v1/login_page.dart new file mode 100644 index 0000000..379f15e --- /dev/null +++ b/lib/historys/chap0215_v1/login_page.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +// 引入九宮格主選單,登入成功後跳轉 +import 'main.dart'; + +// API 相關常數 +const String BASE_IP = "https://api.gex.com.tw:8033"; +const String API_ENDPOINT = "xapi/v1/eis_demo/checklogin/2/"; +const String API_URL = "$BASE_IP/$API_ENDPOINT"; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + // 用於獲取輸入框內容 + final TextEditingController _userController = TextEditingController(text: 'admin'); // 預設帳號 + final TextEditingController _pwdController = TextEditingController(text: 'gex123'); // 預設密碼 + + bool _isLoading = false; + String? _errorMessage; + + // 輔助函數:將字串轉換為 MD5 雜湊值 + String _generateMd5(String input) { + return md5.convert(utf8.encode(input)).toString(); + } + + // 登入邏輯,模擬您的 JS logon() 函數 + Future _logon() async { + if (_userController.text.isEmpty || _pwdController.text.isEmpty) { + setState(() { + _errorMessage = '請輸入帳號和密碼'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final String userId = _userController.text; + final String password = _pwdController.text; + // 對密碼進行 MD5 加密 + final String md5Password = _generateMd5(password); + + // 建立 API 參數,使用您的邏輯 + final Map params = { + 'token': 'xxx', + 'para01': userId, + 'para02': md5Password, + 'para03': 'web', + }; + + try { + // 執行 POST 請求。使用 application/x-www-form-urlencoded 格式 + final response = await http.post( + Uri.parse(API_URL), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params, + ); + + // 解析 API 回應 + final Map responseData = json.decode(response.body); + + // 根據您的 API 邏輯:res.data.code === 0 為成功 + if (responseData['code'] == 0) { + // 登入成功:導航至 MainMenu 並替換登入頁面(防止按返回鍵回到登入) + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const MainMenu()), + ); + } + } else { + // 登入失敗:顯示錯誤訊息 + setState(() { + _errorMessage = '登入失敗,請重新輸入正確的帳號及密碼!'; + }); + } + } catch (e) { + // 網路或伺服器錯誤 + setState(() { + _errorMessage = '網路錯誤或伺服器無法連線。'; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('用戶登入')), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.lock_open, size: 80, color: Colors.blue), + const SizedBox(height: 48.0), + + // 帳號輸入框 + TextField( + controller: _userController, + decoration: const InputDecoration( + labelText: '用戶帳號', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + keyboardType: TextInputType.text, + ), + const SizedBox(height: 16.0), + + // 密碼輸入框 + TextField( + controller: _pwdController, + obscureText: true, + decoration: const InputDecoration( + labelText: '密碼', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + ), + onSubmitted: (_) => _logon(), // 允許按 Enter 鍵登入 + ), + const SizedBox(height: 24.0), + + // 錯誤訊息顯示 + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + + // 登入按鈕 + ElevatedButton( + onPressed: _isLoading ? null : _logon, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 3.0), + ) + : const Text( + '登入', + style: TextStyle(fontSize: 18), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/historys/chap0215_v1/main.dart b/lib/historys/chap0215_v1/main.dart new file mode 100644 index 0000000..199d772 --- /dev/null +++ b/lib/historys/chap0215_v1/main.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import './news_manager.dart'; +import './placeholder_screens.dart'; +import './login_page.dart'; // 確保引入登入頁面 +/* + 您的 Dart HTTP 客戶端可能無法完成與伺服器的 SSL/TLS 握手。這需要將您的整個 App 結構調整為使用 io.HttpClient + 警告: 這是一個臨時且不安全的解決方案。 它會強制 Dart 客戶端信任所有憑證。只建議在無法控制伺服器憑證或開發環境中臨時使用。 + 還需要找時間查一下錯誤原因 20251214 modify by Gemini 3.0 +*/ +import 'dart:io'; + +class MyHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; // 總是回傳 true (忽略 SSL 錯誤) + } +} + +void main() { + // 在 main 函數中加入這行 + HttpOverrides.global = MyHttpOverrides(); + runApp(const ZenApp()); +} + +class ZenApp extends StatelessWidget { + const ZenApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '企業應用主選單', + theme: ThemeData( + primarySwatch: Colors.blue, + // 使用 Material 3 風格 + useMaterial3: true, + ), + // 設置主選單畫面為首頁 + // home: const MainMenu(), + home: const LoginPage(), + ); + } +} + +// ------------------------------------ +// 主選單的資料結構 (Data Model) +// ------------------------------------ + +class MenuItem { + final String title; + final IconData icon; + final Widget targetScreen; + + const MenuItem({ + required this.title, + required this.icon, + required this.targetScreen, + }); +} + +// 所有選單項目的列表 +const List menuItems = [ + // 1. 新聞資訊 (使用您的 NewsManager) + MenuItem( + title: '公告事項', + icon: Icons.article, + targetScreen: NewsManager(), + ), + // 2. 員工打卡 + MenuItem( + title: '員工打卡', + icon: Icons.access_time_filled, + targetScreen: PlaceholderScreen(title: '員工打卡'), + ), + // 2. 員工打卡 + MenuItem( + title: '請假作業', + icon: Icons.access_time_filled, + targetScreen: PlaceholderScreen(title: '請假作業'), + ), // 3. 待簽核事項 + MenuItem( + title: '待簽核事項', + icon: Icons.pending_actions, + targetScreen: PlaceholderScreen(title: '待簽核事項'), + ), + // 4. 待辦事項 + MenuItem( + title: '待辦事項', + icon: Icons.checklist, + targetScreen: PlaceholderScreen(title: '待辦事項'), + ), + // 5. 行事曆 + MenuItem( + title: '行事曆', + icon: Icons.calendar_today, + targetScreen: PlaceholderScreen(title: '行事曆'), + ), + // 6. 其他功能 (佔位) + MenuItem( + title: '會議通知', + icon: Icons.folder, + targetScreen: PlaceholderScreen(title: '會議通知'), + ), + // 6. 其他功能 (佔位) + MenuItem( + title: '產品型錄', + icon: Icons.folder, + targetScreen: PlaceholderScreen(title: '產品型錄'), + ), + // 6. 其他功能 (佔位) + MenuItem( + title: '通訊錄', + icon: Icons.folder, + targetScreen: PlaceholderScreen(title: '通訊錄'), + ), + MenuItem( + title: '業績查詢', + icon: Icons.bar_chart, + targetScreen: PlaceholderScreen(title: '報告查詢'), + ), + MenuItem( + title: 'Issue List', + icon: Icons.bar_chart, + targetScreen: PlaceholderScreen(title: 'Issue List'), + ), + MenuItem( + title: '設定', + icon: Icons.settings, + targetScreen: PlaceholderScreen(title: '設定'), + ), +]; + + +// ------------------------------------ +// 主選單畫面 (MainMenu) - 九宮格實作 +// ------------------------------------ + +class MainMenu extends StatelessWidget { + const MainMenu({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('企業應用主選單'), + // 增加 App Bar 的高度,使其更符合 Material 3 風格 (可選) + toolbarHeight: 70, + // 可以在這裡加入登出按鈕 + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () { + // 登出:導航回登入頁面並清除所有歷史堆棧 + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const LoginPage()), + (Route route) => false, + ); + }, + ), + ], + ), + // 使用 Padding 讓 GridView 內容與邊緣保持距離 + body: Padding( + padding: const EdgeInsets.all(12.0), + // 核心:使用 GridView.count 建立九宮格佈局 + child: GridView.count( + // 每行顯示 3 個項目(實現九宮格的第一條件) + crossAxisCount: 3, + // 項目間的間距 + crossAxisSpacing: 10.0, + mainAxisSpacing: 10.0, + // 遍歷 menuItems 列表來構建每個選單項目 + children: menuItems.map((item) { + return MainMenuItemTile(item: item); + }).toList(), + ), + ), + ); + } +} + +// ------------------------------------ +// 單個選單項目的小部件 (MainMenuItemTile) +// ------------------------------------ + +class MainMenuItemTile extends StatelessWidget { + final MenuItem item; + + const MainMenuItemTile({required this.item, super.key}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4.0, // 增加陰影,更有質感 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)), + child: InkWell( + // 使用 InkWell 獲得點擊時的漣漪效果 + onTap: () { + // 點擊後導航到對應的目標畫面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => item.targetScreen, + ), + ); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 選單圖標 + Icon( + item.icon, + size: 48.0, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 8.0), + // 選單名稱 + Text( + item.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/historys/chap0215_v1/news.dart b/lib/historys/chap0215_v1/news.dart new file mode 100644 index 0000000..6096383 --- /dev/null +++ b/lib/historys/chap0215_v1/news.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +// 1. 確保引入 news_detail.dart +import './news_detail.dart'; + +class News extends StatelessWidget { + final List news; + + // 構造函數保持不變 + const News(this.news, {super.key}); + + @override + Widget build(BuildContext context) { + // 使用 ListView.builder 替代 Column 和 map().toList() + // 這樣可以確保內容可以捲動,並且只構建可見的項目,效率更高。 + return ListView.builder( + // 設置列表的長度,即 news 列表的項目數 + itemCount: news.length, + + // 根據索引來構建每個列表項目 + itemBuilder: (BuildContext context, int index) { + final String currentNewsTitle = news[index]; // 提取當前新聞標題 + + // 使用 news[index] 來取得對應的新聞文本 + return Card( + elevation: 5, // 可選: 增加卡片陰影 + margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), // 增加邊距 + // 在這裡可以選擇添加 Key + key: ValueKey(currentNewsTitle + index.toString()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, // 讓圖片和文字可以寬度延伸 + children: [ + // --- 點擊功能的修改在這裡 --- + GestureDetector( + onTap: () { + // 點擊事件:導航到 NewsDetail 畫面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NewsDetail( + // 2. 將當前新聞的標題/內容傳遞給詳細頁面 + newsTitle: currentNewsTitle, + ), + ), + ); + }, + child: Image.asset( + 'assets/F16-02.jpg', + fit: BoxFit.cover, + height: 200, + ), + ), + // -------------------------- + Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + currentNewsTitle, + style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/historys/chap0215_v1/news_detail.dart b/lib/historys/chap0215_v1/news_detail.dart new file mode 100644 index 0000000..56d768c --- /dev/null +++ b/lib/historys/chap0215_v1/news_detail.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +// 這是一個 StatelessWidget,用於顯示單條新聞的詳細內容 +class NewsDetail extends StatelessWidget { + final String newsTitle; // 接收從前一個畫面傳遞過來的新聞標題 + + // 為了演示,我們假設詳細內容是一個較長的文本 + final String newsDetailContent = + "這是新聞的詳細內容。它包含了更深入的分析、更多的數據和完整的背景資訊。點擊圖片後,使用者可以在這個頁面仔細閱讀。由於這是一個範例,我們使用了一些重複的文本來模擬文章長度,以確保頁面可以滾動。在這個頁面,您可以加入更多的元素,例如不同的圖片、作者資訊、發布日期等。"; + + const NewsDetail({required this.newsTitle, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + // 設置 App Bar,標題可以是傳入的新聞標題 + appBar: AppBar( + title: Text(newsTitle, overflow: TextOverflow.ellipsis), // 防止標題過長 + ), + // 使用 SingleChildScrollView 確保頁面內容超出螢幕時可以滾動 + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 再次顯示圖片 (可選) + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.asset( + 'assets/F16-02.jpg', + fit: BoxFit.cover, + height: 250, + width: double.infinity, // 佔滿寬度 + ), + ), + const SizedBox(height: 16.0), + + // 顯示新聞標題 + Text( + newsTitle, + style: const TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.w800, + ), + ), + const Divider(height: 32.0), // 分隔線 + + // 顯示新聞詳細內容 + Text( + newsDetailContent * 5, // 重複內容 5 次來模擬長文章 + style: const TextStyle( + fontSize: 16.0, + height: 1.5, // 增加行高,改善閱讀體驗 + ), + textAlign: TextAlign.justify, // 內容兩端對齊 + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/historys/chap0215_v1/news_manager.dart b/lib/historys/chap0215_v1/news_manager.dart new file mode 100644 index 0000000..d6e0c1f --- /dev/null +++ b/lib/historys/chap0215_v1/news_manager.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +// 確保你的 news.dart 文件和 News 類已經被正確引入 +import './news.dart'; +// 確保引入 MainMenu 所在的 main.dart 文件,以便導航 +import './main.dart'; + +class NewsManager extends StatefulWidget { + const NewsManager({super.key}); + + @override + State createState() { + return _NewsManagerState(); + } +} + +class _NewsManagerState extends State { + List news = ['第一筆資料: F-16 戰機升級計畫', '第二筆資料: 台積電研發新突破']; + + void _addNews() { + setState(() { + news.add('新增資料:最新動態 ${DateTime.now().second}'); + }); + } + + // 新增的導航函數:返回到主選單 + void _backToHome() { + // Navigator.popUntil 會一直彈出 (Pop) 路由堆棧中的頁面,直到遇到一個 + // 滿足條件 (predicte) 的路由。在這裡,我們使用 (route) => route.isFirst + // 來返回到路由堆棧中的第一個頁面,也就是 MainMenu (如果您的啟動順序是 Login -> MainMenu)。 + // 如果 MainMenu 不是第一個頁面,您可以考慮使用: + // Navigator.popUntil(context, ModalRoute.withName('/main_menu')); + // 但這需要您在 MaterialApp 中為 MainMenu 設定路由名稱。 + + // 最保險且簡單的方法是直接彈出所有,然後重新推入 MainMenu(確保 MainMenu 不會有歷史記錄) + // 或者我們假設 MainMenu 是 Login 成功後的第一個頁面,使用 pushReplacement 清理過的堆棧。 + // 在本例中,我們直接導航回 MainMenu,並清除所有在它之上的頁面。 + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const MainMenu()), + (Route route) => false, // 移除所有前面的路由 + ); + + // 註:若 NewsManager 已經是 Scaffold 的子頁面,使用 Navigator.pop(context) + // 即可返回上一個頁面(即 MainMenu)。但如果想確保是回主頁,用 pushAndRemoveUntil 更明確。 + } + + @override + Widget build(BuildContext context) { + // *** 關鍵修改:將整個頁面包裹在 Scaffold 中以包含 AppBar *** + return Scaffold( + appBar: AppBar( + title: const Text('新聞管理'), + // ---------------------------------------------------- + // 新增的返回主頁按鈕 + actions: [ + IconButton( + icon: const Icon(Icons.home), // 使用一個 Home 圖標 + tooltip: '返回主頁', // 長按時的提示 + onPressed: _backToHome, // 點擊時呼叫導航函數 + ), + ], + // ---------------------------------------------------- + ), + body: Column( + children: [ + // 1. 固定高度的按鈕部分 + Container( + margin: const EdgeInsets.all(10), + child: ElevatedButton( + onPressed: _addNews, + child: const Text( + '新增資料', + style: TextStyle(fontSize: 20, color: Colors.redAccent), + ), + ), + ), + + // 2. 佔據剩餘空間的列表部分 + Expanded( + child: News(news), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/historys/chap0215_v1/placeholder_screens.dart b/lib/historys/chap0215_v1/placeholder_screens.dart new file mode 100644 index 0000000..7389b8b --- /dev/null +++ b/lib/historys/chap0215_v1/placeholder_screens.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +// 通用的佔位畫面 +class PlaceholderScreen extends StatelessWidget { + final String title; + + const PlaceholderScreen({required this.title, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Text( + '$title 功能正在開發中...', + style: const TextStyle(fontSize: 24, color: Colors.grey), + ), + ), + ); + } +} + +// 各功能頁面 (繼承自 PlaceholderScreen,方便未來替換成真實頁面) +class AttendanceScreen extends PlaceholderScreen { + AttendanceScreen({super.key}) : super(title: '員工打卡'); +} + +class ApprovalScreen extends PlaceholderScreen { + ApprovalScreen({super.key}) : super(title: '待簽核事項'); +} + +class TodoScreen extends PlaceholderScreen { + TodoScreen({super.key}) : super(title: '待辦事項'); +} + +class CalendarScreen extends PlaceholderScreen { + CalendarScreen({super.key}) : super(title: '行事曆'); +} \ No newline at end of file diff --git a/lib/issue/issue_api.dart b/lib/issue/issue_api.dart new file mode 100644 index 0000000..8199556 --- /dev/null +++ b/lib/issue/issue_api.dart @@ -0,0 +1,28 @@ +import './issue_model.dart'; +import '../services/generic_api_service.dart'; // 引入共用服務 +import '../auth_manager.dart'; // 確保引入 AuthManager + +class IssueApiService { + final GenericApiService _apiService = GenericApiService(); + final String currentUserId; // 應由登入頁面傳入 + + IssueApiService({this.currentUserId = 'admin'}); + + Future> fetchIssues() async { + // 篩選條件:指派給當前使用者 (responsible = currentUserId) 且狀態非 '已結案' (Status != 4) + // 格式: 1^100^raised_date^*^responsible^=^$currentUserId^issue_status^!=^4 + // 讀取 user_id + String? user_id = await AuthManager.getUserId(); + String filterPart = "responsible^$user_id"; + + // 完整的 queryFilter 格式:Page^PageSize^SortColumn^SortOrder^Filter... + String queryFilter = "1^100^issueid^*^^^$filterPart"; + + return await _apiService.fetchList( + tableName: "pms_issuelog", // 對應到問題追蹤表格 + pk: "issueid", // 主鍵為 issueid + queryFilter: queryFilter, + fromJson: (json) => Issue.fromJson(json), + ); + } +} \ No newline at end of file diff --git a/lib/issue/issue_detail.dart b/lib/issue/issue_detail.dart new file mode 100644 index 0000000..c19ab0f --- /dev/null +++ b/lib/issue/issue_detail.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import './issue_model.dart'; +import 'package:intl/intl.dart'; + +class IssueDetail extends StatelessWidget { + final Issue issue; + + const IssueDetail({required this.issue, super.key}); + + // 輔助函式:建立屬性列 + Widget _buildAttributeRow(BuildContext context, String label, String? value, {Color color = Colors.black}) { + // 處理日期欄位 + String displayValue = value ?? 'N/A'; + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black54), + ), + ), + Expanded( + child: Text( + displayValue, + style: TextStyle(fontWeight: FontWeight.w600, color: color), + ), + ), + ], + ), + ); + } + + // 輔助函式:格式化日期時間 + String _formatDateTime(DateTime? dateTime) { + if (dateTime == null) return 'N/A'; + return DateFormat('yyyy/MM/dd HH:mm').format(dateTime); + } + + + @override + Widget build(BuildContext context) { + // 完整描述 (Model中可能被截斷,這裡使用原始欄位) + final fullDescription = issue.description ?? '無詳細描述。'; + final hasSolution = issue.solution != null && issue.solution!.isNotEmpty; + + return Scaffold( + appBar: AppBar( + title: Text('問題 #${issue.issueId}'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 標題與 ID + Text( + '問題 ID: #${issue.issueId}', + style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87), + ), + + const Divider(height: 24.0), + + // 核心屬性 + _buildAttributeRow(context, '狀態', issue.statusText, color: issue.statusColor), + _buildAttributeRow(context, '優先級', issue.priority, color: issue.priorityColor), + _buildAttributeRow(context, '預計完成日', issue.formattedExpectedDate, color: Colors.blue), + _buildAttributeRow(context, '指派對象', issue.responsible), + _buildAttributeRow(context, '提出人', issue.raisedBy), + _buildAttributeRow(context, '專案 ID', issue.projectId), + _buildAttributeRow(context, '功能碼', issue.functionCode), + _buildAttributeRow(context, '提出時間', _formatDateTime(issue.raisedDate)), + _buildAttributeRow(context, '解決說明', hasSolution ? '詳見下方' : '尚未解決', color: hasSolution ? Colors.green : Colors.red), + + const Divider(height: 32.0), + + // 問題詳細描述 + const Text( + '問題描述 (Issue Description):', + style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + fullDescription, + style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black87), + textAlign: TextAlign.justify, + ), + ), + + const Divider(height: 32.0), + + // 解決說明/備註 + Text( + '解決說明 (Solution Explanation):', + style: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w600, + color: hasSolution ? Colors.black87 : Colors.grey.shade500 + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + width: double.infinity, + decoration: BoxDecoration( + color: hasSolution ? Colors.lightGreen.shade50 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: hasSolution ? Colors.green.shade200 : Colors.transparent) + ), + child: Text( + issue.solution ?? '尚無解決說明或備註。', + style: TextStyle( + fontSize: 16.0, + height: 1.5, + color: hasSolution ? Colors.black87 : Colors.grey.shade600 + ), + textAlign: TextAlign.justify, + ), + ), + const SizedBox(height: 40), + + // 底部按鈕 + Center( + child: ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('待實作處理問題流程(例如:變更狀態)。')), + ); + }, + icon: const Icon(Icons.build), + label: const Text('處理問題'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + textStyle: const TextStyle(fontSize: 18), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/issue/issue_manager.dart b/lib/issue/issue_manager.dart new file mode 100644 index 0000000..f8dd80e --- /dev/null +++ b/lib/issue/issue_manager.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import './issue_api.dart'; +import './issue_model.dart'; +import './issue_detail.dart'; + +class IssueManager extends StatefulWidget { + // 實際應用中,這裡應該傳入當前用戶 ID + final String currentUserId; + const IssueManager({this.currentUserId = 'admin', super.key}); + + @override + State createState() { + return _IssueManagerState(); + } +} + +class _IssueManagerState extends State { + late IssueApiService _apiService; + late Future> _issuesFuture; + + @override + void initState() { + super.initState(); + _apiService = IssueApiService(currentUserId: widget.currentUserId); + _issuesFuture = _apiService.fetchIssues(); + } + + void _refreshIssues() { + setState(() { + _issuesFuture = _apiService.fetchIssues(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('指派給我的問題清單 (${widget.currentUserId})'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: '刷新列表', + onPressed: _refreshIssues, + ), + IconButton( + icon: const Icon(Icons.home), + tooltip: '返回主頁', + onPressed: () => Navigator.pop(context), + ), + ], + ), + body: FutureBuilder>( + future: _issuesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton(onPressed: _refreshIssues, child: const Text('重試')), + ], + ), + ); + } else if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return IssueList(issues: snapshot.data!); + } else { + return const Center(child: Text('目前沒有指派給您的問題。')); + } + }, + ), + // 底部浮動按鈕:新增問題 + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('待實作新增問題頁面。')), + ); + }, + icon: const Icon(Icons.add), + label: const Text('新增問題'), + backgroundColor: Colors.redAccent, + ), + ); + } +} + +// ----------------------------------------------------------- +// 列表顯示小部件 (IssueList) +// ----------------------------------------------------------- + +class IssueList extends StatelessWidget { + final List issues; + + const IssueList({required this.issues, super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: issues.length, + itemBuilder: (BuildContext context, int index) { + final item = issues[index]; + + return Card( + elevation: 3, + margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0), + child: InkWell( + onTap: () { + // 點擊項目:導航到詳細頁面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => IssueDetail(issue: item), + ), + ); + }, + child: ListTile( + // 左側顯示優先級 + leading: Icon( + item.priorityIcon, + color: item.priorityColor, + size: 30, + ), + title: Text( + '#${item.issueId} ${item.description ?? '無描述'}', + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '專案: ${item.projectId ?? 'N/A'} | 提出人: ${item.raisedBy ?? 'N/A'}', + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + item.statusText, + style: TextStyle(fontSize: 12, color: item.statusColor, fontWeight: FontWeight.bold), + ), + Text( + item.formattedExpectedDate, + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/issue/issue_model.dart b/lib/issue/issue_model.dart new file mode 100644 index 0000000..d365be2 --- /dev/null +++ b/lib/issue/issue_model.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class Issue { + final int issueId; // issueid (Primary Key) + final String? projectId; // projectid + final String? functionCode; // functioncode + final String? description; // issue_description + final String? priority; // issue_priority (High, Medium, Low) + final int? status; // issue_status (1=New, 2=InProgress, 3=Resolved, 4=Closed) + final String? raisedBy; // raised_by + final String? responsible; // responsible + final DateTime? raisedDate; // raised_date + final DateTime? expectedDate; // excepted_date + final String? solution; // solution_explanation + + Issue({ + required this.issueId, + this.projectId, + this.functionCode, + this.description, + this.priority, + this.status, + this.raisedBy, + this.responsible, + this.raisedDate, + this.expectedDate, + this.solution, + }); + + factory Issue.fromJson(Map json) { + DateTime? parseDate(dynamic date) { + if (date is String && date.isNotEmpty) { + return DateTime.tryParse(date); + } + return null; + } + + // 由於 issue_priority 是 VARCHAR,我們假設它直接就是 'High', 'Medium', 'Low' + // 或是一個代號,此處保留為 String + final rawPriority = json['issue_priority'] as String?; + + // 假設 issue_description 欄位是問題標題/簡述 + final rawDescription = json['issue_description'] as String?; + + final descriptionLength = rawDescription?.length ?? 0; + final truncatedDescription = (descriptionLength > 50) + ? rawDescription!.substring(0, 50) + '...' // 列表截斷 + : rawDescription; + + return Issue( + issueId: json['issueid'] as int? ?? 0, + projectId: json['projectid'] as String?, + functionCode: json['functioncode'] as String?, + description: truncatedDescription, // 使用修正後的變數 + priority: rawPriority, + status: json['issue_status'] as int?, + raisedBy: json['raised_by'] as String?, + responsible: json['responsible'] as String?, + raisedDate: parseDate(json['raised_date']), + expectedDate: parseDate(json['excepted_date']), + solution: json['solution_explanation'] as String?, + ); + } + + // Helper: 格式化預計完成日期 + String get formattedExpectedDate { + if (expectedDate == null) return 'N/A'; + return DateFormat('yyyy/MM/dd').format(expectedDate!); + } + + // Helper: 獲取狀態文字 + String get statusText { + switch (status) { + case 1: + return '新建'; + case 2: + return '進行中'; + case 3: + return '已解決'; + case 4: + return '已結案'; + default: + return '未知'; + } + } + + // Helper: 獲取狀態顏色 + Color get statusColor { + switch (status) { + case 1: + return Colors.red; // 新建 + case 2: + return Colors.orange; // 進行中 + case 3: + return Colors.blue; // 已解決 + case 4: + return Colors.green; // 已結案 + default: + return Colors.grey; + } + } + + // Helper: 獲取優先級圖示 + IconData get priorityIcon { + switch (priority?.toLowerCase()) { + case 'high': + return Icons.arrow_upward; + case 'medium': + return Icons.remove; + case 'low': + return Icons.arrow_downward; + default: + return Icons.sort; + } + } + + // Helper: 獲取優先級顏色 + Color get priorityColor { + switch (priority?.toLowerCase()) { + case 'high': + return Colors.red.shade700; + case 'medium': + return Colors.orange.shade700; + case 'low': + return Colors.blue.shade700; + default: + return Colors.grey; + } + } +} \ No newline at end of file diff --git a/lib/leave/leave_api.dart b/lib/leave/leave_api.dart new file mode 100644 index 0000000..c37ce7d --- /dev/null +++ b/lib/leave/leave_api.dart @@ -0,0 +1,44 @@ +import './leave_model.dart'; +import '../services/generic_api_service.dart'; +import 'package:intl/intl.dart'; + +class LeaveApiService { + final GenericApiService _apiService = GenericApiService(); + + // 獲取個人請假紀錄 + Future> fetchLeaves(String personId) async { + // 排序:按單據日期降冪 + String queryFilter = "1^100^billdate^*^personid^=^$personId"; + + return await _apiService.fetchList( + tableName: "hrs_leave", + pk: "billno", + queryFilter: queryFilter, + fromJson: (json) => Leave.fromJson(json), + ); + } + + // 提交請假單 (新增) + Future createLeave(Leave leave) async { + final Map data = { + "billdate": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), + "billno": "LV${DateTime.now().millisecondsSinceEpoch}", // 範例編號 + "personid": leave.personId, + "agentid": leave.agentId, + "leavetype": leave.leaveType, + "starttime": DateFormat('yyyy-MM-dd HH:mm:ss').format(leave.startTime!), + "endtime": DateFormat('yyyy-MM-dd HH:mm:ss').format(leave.endTime!), + "days": leave.days, + "hours": leave.hours, + "leave_note": leave.leaveNote, + "flow_status": "1", // 提交即進入審核中 + "create_user": leave.personId, + "create_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), + }; + + // 呼叫底層 saveData 實作 + // return await _apiService.saveData("hrs_leave", data); + print("提交假單: $data"); + return true; + } +} \ No newline at end of file diff --git a/lib/leave/leave_detail.dart b/lib/leave/leave_detail.dart new file mode 100644 index 0000000..6bf00b3 --- /dev/null +++ b/lib/leave/leave_detail.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import './leave_model.dart'; + +class LeaveDetail extends StatelessWidget { + final Leave leave; + + const LeaveDetail({required this.leave, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('請假單詳情'), + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0.5, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 頂部狀態區塊 + _buildHeaderStatus(), + const SizedBox(height: 24), + + // 主要資訊區塊 (使用卡片包裝) + _buildInfoCard(context), + const SizedBox(height: 24), + + // 請假事由區塊 + const Text( + '請假事由', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + ), + child: Text( + leave.leaveNote ?? '未填寫事由', + style: const TextStyle(fontSize: 15, height: 1.5), + ), + ), + + const SizedBox(height: 32), + + // 底部操作按鈕 (例如:若為草稿可編輯,或撤回) + if (leave.flowStatus == '0' || leave.flowStatus == '1') + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + // 實作撤回或取消邏輯 + }, + icon: const Icon(Icons.history_outlined), + label: const Text('撤回申請'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ), + ); + } + + // 頂部狀態顯示:呈現單號與醒目的狀態標籤 + Widget _buildHeaderStatus() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '單號: ${leave.billNo}', + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 4), + Text( + leave.leaveType, + style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + ], + ), + Chip( + backgroundColor: leave.statusColor.withOpacity(0.1), + side: BorderSide(color: leave.statusColor), + label: Text( + leave.statusText, + style: TextStyle(color: leave.statusColor, fontWeight: FontWeight.bold), + ), + ), + ], + ); + } + + // 核心資訊卡片 + Widget _buildInfoCard(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + side: BorderSide(color: Colors.grey.shade200), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _buildDetailRow(Icons.calendar_month, '請假期間', leave.formattedRange), + const Divider(height: 30), + _buildDetailRow(Icons.timer_outlined, '請假時數', '${leave.days} 天 ${leave.hours} 小時'), + const Divider(height: 30), + _buildDetailRow(Icons.person_outline, '代理人', leave.agentId), + const Divider(height: 30), + _buildDetailRow(Icons.edit_calendar, '申請日期', + leave.billDate != null ? DateFormat('yyyy-MM-dd').format(leave.billDate!) : 'N/A'), + ], + ), + ), + ); + } + + // 輔助元件:建立細節列 + Widget _buildDetailRow(IconData icon, String label, String value) { + return Row( + children: [ + Icon(icon, size: 20, color: Colors.blueAccent), + const SizedBox(width: 12), + Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)), + const Spacer(), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/leave/leave_form.dart b/lib/leave/leave_form.dart new file mode 100644 index 0000000..50698dc --- /dev/null +++ b/lib/leave/leave_form.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import './leave_model.dart'; +import './leave_api.dart'; +import 'package:intl/intl.dart'; + +class LeaveForm extends StatefulWidget { + final String userId; + const LeaveForm({required this.userId, super.key}); + + @override + State createState() => _LeaveFormState(); +} + +class _LeaveFormState extends State { + final _formKey = GlobalKey(); + String _selectedType = '事假'; + String _agentId = ''; + String _note = ''; + DateTime _start = DateTime.now(); + DateTime _end = DateTime.now().add(const Duration(hours: 8)); + + final List _types = ['事假', '病假', '特休', '婚假', '喪假']; + + void _submit() async { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + + final newLeave = Leave( + billNo: '', // API 端生成 + personId: widget.userId, + agentId: _agentId, + leaveType: _selectedType, + startTime: _start, + endTime: _end, + days: 1.0, // 簡化處理,實際可依 start/end 計算 + hours: 8.0, + leaveNote: _note, + ); + + final success = await LeaveApiService().createLeave(newLeave); + if (success && mounted) { + Navigator.pop(context, true); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('新增請假申請')), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + DropdownButtonFormField( + value: _selectedType, + decoration: const InputDecoration(labelText: '請假類別'), + items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), + onChanged: (v) => setState(() => _selectedType = v!), + ), + const SizedBox(height: 16), + TextFormField( + decoration: const InputDecoration(labelText: '代理人工號'), + validator: (v) => v!.isEmpty ? '必填' : null, + onSaved: (v) => _agentId = v!, + ), + const SizedBox(height: 16), + ListTile( + title: const Text('開始時間'), + subtitle: Text(DateFormat('yyyy/MM/dd HH:mm').format(_start)), + trailing: const Icon(Icons.calendar_today), + onTap: () async { + // 這裡簡化,實務上可串接 showDatePicker + showTimePicker + }, + ), + const SizedBox(height: 16), + TextFormField( + decoration: const InputDecoration(labelText: '事由說明'), + maxLines: 3, + onSaved: (v) => _note = v!, + ), + const SizedBox(height: 30), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)), + child: const Text('提交申請'), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/leave/leave_manager.dart b/lib/leave/leave_manager.dart new file mode 100644 index 0000000..d837866 --- /dev/null +++ b/lib/leave/leave_manager.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import './leave_model.dart'; +import './leave_api.dart'; +import './leave_form.dart'; // 稍後定義的新增頁面 +import './leave_detail.dart'; + +class LeaveManager extends StatefulWidget { + final String currentUserId; + const LeaveManager({required this.currentUserId, super.key}); + + @override + State createState() => _LeaveManagerState(); +} + +class _LeaveManagerState extends State { + late LeaveApiService _apiService; + late Future> _leaveFuture; + + @override + void initState() { + super.initState(); + _apiService = LeaveApiService(); + _refreshList(); + } + + void _refreshList() { + setState(() { + _leaveFuture = _apiService.fetchLeaves(widget.currentUserId); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('請假紀錄')), + body: FutureBuilder>( + future: _leaveFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('尚無請假紀錄')); + } + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (ctx, i) => _buildLeaveCard(snapshot.data![i]), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => LeaveForm(userId: widget.currentUserId)), + ); + if (result == true) _refreshList(); + }, + label: const Text('申請請假'), + icon: const Icon(Icons.add), + ), + ); + } + + Widget _buildLeaveCard(Leave item) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => LeaveDetail(leave: item)), + ); + }, + title: Text('${item.leaveType} (${item.days}天 ${item.hours}時)'), + subtitle: Text(item.formattedRange), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: item.statusColor, borderRadius: BorderRadius.circular(5)), + child: Text(item.statusText, style: const TextStyle(color: Colors.white, fontSize: 12)), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/leave/leave_model.dart b/lib/leave/leave_model.dart new file mode 100644 index 0000000..3b34a01 --- /dev/null +++ b/lib/leave/leave_model.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class Leave { + final String billNo; // billno (Primary Key) + final DateTime? billDate; // billdate + final String personId; // personid + final String agentId; // agentid (代理人) + final String leaveType; // leavetype (假別:事假、病假等) + final DateTime? startTime; // starttime + final DateTime? endTime; // endtime + final double days; // days + final double hours; // hours + final String? leaveNote; // leave_note + final String? flowStatus; // flow_status (0:草稿, 1:審核中, 2:已核准, X:駁回) + + Leave({ + required this.billNo, + this.billDate, + required this.personId, + required this.agentId, + required this.leaveType, + this.startTime, + this.endTime, + this.days = 0, + this.hours = 0, + this.leaveNote, + this.flowStatus, + }); + + factory Leave.fromJson(Map json) { + return Leave( + billNo: json['billno'] as String? ?? '', + billDate: json['billdate'] != null ? DateTime.tryParse(json['billdate']) : null, + personId: json['personid'] as String? ?? '', + agentId: json['agentid'] as String? ?? '', + leaveType: json['leavetype'] as String? ?? '', + startTime: json['starttime'] != null ? DateTime.tryParse(json['starttime']) : null, + endTime: json['endtime'] != null ? DateTime.tryParse(json['endtime']) : null, + days: double.tryParse(json['days']?.toString() ?? '0') ?? 0, + hours: double.tryParse(json['hours']?.toString() ?? '0') ?? 0, + leaveNote: json['leave_note'] as String?, + flowStatus: json['flow_status'] as String?, + ); + } + + // 格式化顯示 + String get formattedRange { + if (startTime == null || endTime == null) return '時間未定'; + final df = DateFormat('yyyy/MM/dd HH:mm'); + return '${df.format(startTime!)} ~ ${df.format(endTime!)}'; + } + + // 狀態顏色映射 + Color get statusColor { + switch (flowStatus) { + case '1': return Colors.orange; // 審核中 + case '2': return Colors.green; // 已核准 + case 'X': return Colors.red; // 駁回 + default: return Colors.grey; // 草稿 + } + } + + String get statusText { + switch (flowStatus) { + case '1': return '審核中'; + case '2': return '已核准'; + case 'X': return '已駁回'; + default: return '草稿'; + } + } +} \ No newline at end of file diff --git a/lib/login_page.dart b/lib/login_page.dart new file mode 100644 index 0000000..1b6b98f --- /dev/null +++ b/lib/login_page.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +// 引入九宮格主選單,登入成功後跳轉 +import 'main.dart'; +import './auth_manager.dart'; // 確保路徑正確 + +// API 相關常數 +const String BASE_IP = "https://api.gex.com.tw:8033"; +const String API_ENDPOINT = "xapi/v1/eis_demo/checklogin/2/"; +const String API_URL = "$BASE_IP/$API_ENDPOINT"; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + // 用於獲取輸入框內容 + final TextEditingController _userController = TextEditingController(text: 'admin'); // 預設帳號 + final TextEditingController _pwdController = TextEditingController(text: 'gex123'); // 預設密碼 + + bool _isLoading = false; + String? _errorMessage; + + // 輔助函數:將字串轉換為 MD5 雜湊值 + String _generateMd5(String input) { + return md5.convert(utf8.encode(input)).toString(); + } + + // 登入邏輯,模擬您的 JS logon() 函數 + Future _logon() async { + if (_userController.text.isEmpty || _pwdController.text.isEmpty) { + setState(() { + _errorMessage = '請輸入帳號和密碼'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final String userId = _userController.text; + final String password = _pwdController.text; + // 對密碼進行 MD5 加密 + final String md5Password = _generateMd5(password); + + // 建立 API 參數,使用您的邏輯 + final Map params = { + 'token': 'xxx', + 'para01': userId, + 'para02': md5Password, + 'para03': 'web', + }; + + try { + // 執行 POST 請求。使用 application/x-www-form-urlencoded 格式 + final response = await http.post( + Uri.parse(API_URL), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params, + ); + + // 解析 API 回應 + final Map responseData = json.decode(response.body); + + // 根據您的 API 邏輯:res.data.code === 0 為成功 + if (responseData['code'] == 0) { + // 1. 預期 responseData['data'] 是一個 List + final List? dataList = responseData['data'] as List?; + + // 2. 檢查 List 不為空,並從第一個元素中提取 'token' + final String? token = (dataList != null && dataList.isNotEmpty) + ? (dataList[0] as Map)['token'] as String? + : null; + + if (token != null && token.isNotEmpty) { + await AuthManager.saveLoginInfo(token, userId); // 呼叫 AuthManager 儲存 Token + } else { + print("警告: 登入成功但未收到 Token,將不儲存。"); + } + + // 登入成功:導航至 MainMenu 並替換登入頁面(防止按返回鍵回到登入) + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const MainMenu()), + ); + } + } else { + // 登入失敗:顯示錯誤訊息 + setState(() { + _errorMessage = '登入失敗,請重新輸入正確的帳號及密碼!'; + }); + } + } catch (e) { + // 網路或伺服器錯誤 + setState(() { + _errorMessage = '網路錯誤或伺服器無法連線。'; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('用戶登入')), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.lock_open, size: 80, color: Colors.blue), + const SizedBox(height: 48.0), + + // 帳號輸入框 + TextField( + controller: _userController, + decoration: const InputDecoration( + labelText: '用戶帳號', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + keyboardType: TextInputType.text, + ), + const SizedBox(height: 16.0), + + // 密碼輸入框 + TextField( + controller: _pwdController, + obscureText: true, + decoration: const InputDecoration( + labelText: '密碼', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + ), + onSubmitted: (_) => _logon(), // 允許按 Enter 鍵登入 + ), + const SizedBox(height: 24.0), + + // 錯誤訊息顯示 + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + + // 登入按鈕 + ElevatedButton( + onPressed: _isLoading ? null : _logon, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 3.0), + ) + : const Text( + '登入', + style: TextStyle(fontSize: 18), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..ba902cf --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'News/news_manager.dart'; +import './placeholder_screens.dart'; +import './login_page.dart'; // 確保引入登入頁面 +import './auth_manager.dart'; +/* + 您的 Dart HTTP 客戶端可能無法完成與伺服器的 SSL/TLS 握手。這需要將您的整個 App 結構調整為使用 io.HttpClient + 警告: 這是一個臨時且不安全的解決方案。 它會強制 Dart 客戶端信任所有憑證。只建議在無法控制伺服器憑證或開發環境中臨時使用。 + 還需要找時間查一下錯誤原因 20251214 modify by Gemini 3.0 +*/ +import 'dart:io'; + +// 確保引入新的公告管理器 +import './AnnouncementManager/announcement_manager.dart'; +// 確保引入新的待辦事項管理器 +import './todo/todo_manager.dart'; +// 通訊錄 +import './employee/person_manager.dart'; +// 會議通知 +import './meeting/meeting_manager.dart'; +// Issue +import './issue/issue_manager.dart'; +// setting +import './setup/settings_page.dart'; +import './clockin/clock_in_manager.dart'; +import './leave/leave_manager.dart'; +import './calendar/calendar_manager.dart'; + +class MyHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; // 總是回傳 true (忽略 SSL 錯誤) + } +} + +void main() { + // 在 main 函數中加入這行 + HttpOverrides.global = MyHttpOverrides(); + runApp(const ZenApp()); +} + +class ZenApp extends StatelessWidget { + const ZenApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '企業應用主選單', + theme: ThemeData( + primarySwatch: Colors.blue, + // 使用 Material 3 風格 + useMaterial3: true, + ), + // 設置主選單畫面為首頁 + // home: const MainMenu(), + home: const LoginPage(), + ); + } +} + +// ------------------------------------ +// 主選單的資料結構 (Data Model) +// ------------------------------------ + +class MenuItem { + final String title; + final IconData icon; + final Widget targetScreen; + + const MenuItem({ + required this.title, + required this.icon, + required this.targetScreen, + }); +} + +// 所有選單項目的列表 +const List menuItems = [ + // 1. 新聞資訊 (使用您的 NewsManager) + MenuItem( + title: '公告事項', + icon: Icons.article, + //targetScreen: NewsManager(), + targetScreen: AnnouncementManager(), + ), + // 2. 員工打卡 + MenuItem( + title: '員工打卡', + icon: Icons.access_time_filled, + targetScreen: ClockInManager(userId: "admin"), + ), + // 2. 員工打卡 + MenuItem( + title: '請假作業', + icon: Icons.event_available, + targetScreen: LeaveManager(currentUserId: "admin"), + ), // 3. 待簽核事項 + MenuItem( + title: '待簽核事項', + icon: Icons.pending_actions, + targetScreen: PlaceholderScreen(title: '待簽核事項'), + ), + // 4. 待辦事項 + MenuItem( + title: '待辦事項', + icon: Icons.checklist, + targetScreen: TodoManager(), + ), + // 5. 行事曆 + MenuItem( + title: '行事曆', + icon: Icons.calendar_today, + targetScreen: CalendarManager(userId: "120102"), + ), + // 6. 其他功能 (佔位) + MenuItem( + title: '會議通知', + icon: Icons.people_alt, + targetScreen: MeetingManager(), + ), + // 6. 其他功能 (佔位) + MenuItem( + title: '產品型錄', + icon: Icons.local_mall, + targetScreen: PlaceholderScreen(title: '產品型錄'), + ), + // 6. 其他功能 (佔位) + MenuItem( + title: '通訊錄', + icon: Icons.contact_phone, + targetScreen: PersonManager(), + ), + MenuItem( + title: '業績查詢', + icon: Icons.query_stats, + targetScreen: PlaceholderScreen(title: '報告查詢'), + ), + MenuItem( + title: 'Issue List', + icon: Icons.bug_report, + targetScreen: IssueManager(), + ), + MenuItem( + title: '設定', + icon: Icons.settings, + targetScreen: SettingsPage(), + ), + MenuItem( + title: '新聞(Demo)', + icon: Icons.article, + targetScreen: NewsManager(), + //targetScreen: AnnouncementManager(), + ), +]; + + +// ------------------------------------ +// 主選單畫面 (MainMenu) - 九宮格實作 +// ------------------------------------ + +class MainMenu extends StatelessWidget { + const MainMenu({super.key}); + + // 公司資訊變數 + final String companyName = "中華開放原始碼應用推廣協會"; + final String slogan = "推動開源 × 數位轉型 × 企業創新"; + final String copyrightYear = "2024"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('企業應用主選單'), + // 增加 App Bar 的高度,使其更符合 Material 3 風格 (可選) + toolbarHeight: 70, + // 可以在這裡加入登出按鈕 + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () async { + await AuthManager.logout(); // <-- 記得加入此行 + + // 登出:導航回登入頁面並清除所有歷史堆棧 + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const LoginPage()), + (Route route) => false, + ); + }, + ), + ], + ), + // 使用 Column 將 GridView 和底部資訊垂直堆疊 + body: Column( + children: [ + // 1. 主要內容區 (Grid View) - 使用 Expanded 佔滿剩餘空間 + Expanded( + child: Padding( + padding: const EdgeInsets.all(12.0), + // 核心:使用 GridView.count 建立九宮格佈局 + child: GridView.count( + // 每行顯示 3 個項目(實現九宮格的第一條件) + crossAxisCount: 3, + // 項目間的間距 + crossAxisSpacing: 10.0, + mainAxisSpacing: 10.0, + // 遍歷 menuItems 列表來構建每個選單項目 + children: menuItems.map((item) { + return MainMenuItemTile(item: item); + }).toList(), + ), + ), + ), + // 2. 底部公司資訊、Slogan 和版權宣告 (固定在最下方) + Container( + padding: const EdgeInsets.only(top: 8.0, bottom: 16.0), + width: double.infinity, // 佔滿寬度 + child: Column( + children: [ + // 公司名稱 + Text( + companyName, + style: TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 4.0), + // Slogan + Text( + slogan, + style: TextStyle( + fontSize: 12.0, + fontStyle: FontStyle.italic, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8.0), + // 版權宣告 + /* + Text( + 'Copyright © $copyrightYear $companyName. All rights reserved.', + style: TextStyle( + fontSize: 10.0, + color: Colors.grey[500], + ), + ), + */ + ], + ), + ), + ], + ), + ); + } +} + +// ------------------------------------ +// 單個選單項目的小部件 (MainMenuItemTile) +// ------------------------------------ + +class MainMenuItemTile extends StatelessWidget { + final MenuItem item; + + const MainMenuItemTile({required this.item, super.key}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4.0, // 增加陰影,更有質感 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)), + child: InkWell( + // 使用 InkWell 獲得點擊時的漣漪效果 + onTap: () { + // 點擊後導航到對應的目標畫面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => item.targetScreen, + ), + ); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 選單圖標 + Icon( + item.icon, + size: 48.0, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 8.0), + // 選單名稱 + Text( + item.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/meeting/meeting_api.dart b/lib/meeting/meeting_api.dart new file mode 100644 index 0000000..f268c0a --- /dev/null +++ b/lib/meeting/meeting_api.dart @@ -0,0 +1,28 @@ +import './meeting_model.dart'; +import '../services/generic_api_service.dart'; // 引入共用服務 + +class MeetingApiService { + final GenericApiService _apiService = GenericApiService(); + final String currentUserId; // 應由登入頁面傳入 + + MeetingApiService({this.currentUserId = 'admin'}); + + Future> fetchMeetings() async { + // 目標:獲取與當前用戶 (currentUserId) 相關的會議通知。 + // 採用過濾邏輯:篩選參與者名單 (meeting_users) 中包含 currentUserId 的記錄。 + + // 預設參數:頁碼1,每頁100筆,按 startdate 降冪排序 (*表示降冪) + // 格式: 1^100^startdate^*^meeting_users^LIKE^%currentUserId% + String filterPart = "startdate^*^meeting_users^LIKE^%$currentUserId%"; + + // 完整的 queryFilter 格式:Page^PageSize^SortColumn^SortOrder^FilterColumn^Operator^FilterValue... + String queryFilter = "1^100^$filterPart"; + + return await _apiService.fetchList( + tableName: "eipmeetingrec_m", // 對應到會議通知表格 + pk: "uniqueno", // 主鍵為 uniqueno + queryFilter: queryFilter, + fromJson: (json) => Meeting.fromJson(json), + ); + } +} \ No newline at end of file diff --git a/lib/meeting/meeting_detail.dart b/lib/meeting/meeting_detail.dart new file mode 100644 index 0000000..6b88458 --- /dev/null +++ b/lib/meeting/meeting_detail.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import './meeting_model.dart'; +import 'package:intl/intl.dart'; + +class MeetingDetail extends StatelessWidget { + final Meeting meeting; + + const MeetingDetail({required this.meeting, super.key}); + + // 輔助函式:建立屬性列 (從 todo_detail.dart 最佳化而來) + Widget _buildAttributeRow(BuildContext context, String label, String value, {Color color = Colors.black}) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black54), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle(fontWeight: FontWeight.w600, color: color), + ), + ), + ], + ), + ); + } + + // 輔助函式:格式化日期時間範圍 + String _formatDateRange() { + final dateFormat = DateFormat('yyyy/MM/dd'); + final startDateStr = meeting.startDate != null ? dateFormat.format(meeting.startDate!) : 'N/A'; + final endDateStr = meeting.endDate != null ? dateFormat.format(meeting.endDate!) : startDateStr; + + // 檢查日期是否相同 + final isSameDate = meeting.startDate != null && meeting.endDate != null && + meeting.startDate!.day == meeting.endDate!.day && + meeting.startDate!.month == meeting.endDate!.month && + meeting.startDate!.year == meeting.endDate!.year; + + String dateRange; + if (isSameDate) { + dateRange = startDateStr; + } else { + dateRange = '$startDateStr 至 $endDateStr'; + } + + final timeRange = (meeting.startTime ?? '') + ' ~ ' + (meeting.endTime ?? ''); + return '$dateRange ${timeRange.trim()}'; + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(meeting.meetingTitle, overflow: TextOverflow.ellipsis), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 會議標題 + Text( + meeting.meetingTitle, + style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87), + ), + const Divider(height: 24.0), + + // 會議屬性表格 + _buildAttributeRow(context, '狀態', meeting.statusText, color: meeting.statusColor), + _buildAttributeRow(context, '地點', meeting.meetingPlace ?? '待定'), + _buildAttributeRow(context, '時間範圍', _formatDateRange(), color: Colors.blue), + _buildAttributeRow(context, '主持人 ID', meeting.bossPersonId ?? 'N/A'), + _buildAttributeRow(context, '記錄人 ID', meeting.recPersonId ?? 'N/A'), + _buildAttributeRow(context, '參與者名單', meeting.meetingUsers ?? '無'), + + const Divider(height: 32.0), + + // 會議說明 + const Text( + '會議說明:', + style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87), + ), + const SizedBox(height: 8), + Text( + meeting.meetingDesc ?? '無詳細說明。', + style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black54), + textAlign: TextAlign.justify, + ), + + const Divider(height: 32.0), + + // 會議決議 + const Text( + '會議決議:', + style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87), + ), + const SizedBox(height: 8), + Text( + meeting.resolution ?? '無決議事項。', + style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black54), + textAlign: TextAlign.justify, + ), + + const SizedBox(height: 40), + + // 底部按鈕 (範例:下載文件) + Center( + child: ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('待實作下載會議文件功能。')), + ); + }, + icon: const Icon(Icons.file_download), + label: const Text('下載會議文件'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + textStyle: const TextStyle(fontSize: 18), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/meeting/meeting_manager.dart b/lib/meeting/meeting_manager.dart new file mode 100644 index 0000000..1440fe7 --- /dev/null +++ b/lib/meeting/meeting_manager.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import './meeting_api.dart'; +import './meeting_model.dart'; +import './meeting_detail.dart'; + +class MeetingManager extends StatefulWidget { + // 實際應用中,這裡應該傳入當前用戶 ID + final String currentUserId; + const MeetingManager({this.currentUserId = 'admin', super.key}); + + @override + State createState() { + return _MeetingManagerState(); + } +} + +class _MeetingManagerState extends State { + late MeetingApiService _apiService; + late Future> _meetingsFuture; + + @override + void initState() { + super.initState(); + // 服務初始化時傳入當前用戶 ID + _apiService = MeetingApiService(currentUserId: widget.currentUserId); + _meetingsFuture = _apiService.fetchMeetings(); + } + + // 刷新資料的函數 + void _refreshMeetings() { + setState(() { + _meetingsFuture = _apiService.fetchMeetings(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('我的會議通知'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: '刷新列表', + onPressed: _refreshMeetings, + ), + IconButton( + icon: const Icon(Icons.home), + tooltip: '返回主頁', + onPressed: () => Navigator.pop(context), + ), + ], + ), + body: FutureBuilder>( + future: _meetingsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton(onPressed: _refreshMeetings, child: const Text('重試')), + ], + ), + ); + } else if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return MeetingList(meetings: snapshot.data!); + } else { + return const Center(child: Text('目前沒有相關會議通知。')); + } + }, + ), + ); + } +} + +// ----------------------------------------------------------- +// 列表顯示小部件 (MeetingList) +// ----------------------------------------------------------- + +class MeetingList extends StatelessWidget { + final List meetings; + + const MeetingList({required this.meetings, super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: meetings.length, + itemBuilder: (BuildContext context, int index) { + final item = meetings[index]; + + return Card( + elevation: 3, + margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0), + child: InkWell( + onTap: () { + // 點擊項目:導航到詳細頁面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MeetingDetail(meeting: item), + ), + ); + }, + child: ListTile( + // 左側圖示顯示 + leading: Icon( + Icons.calendar_month, + color: item.statusColor, + size: 32, + ), + title: Text( + item.meetingTitle, + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '地點: ${item.meetingPlace ?? '待定'} | 主持: ${item.bossPersonId ?? 'N/A'}', + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + item.statusText, + style: TextStyle(fontSize: 10, color: item.statusColor), + ), + Text( + item.formattedStartDateTime, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/meeting/meeting_model.dart b/lib/meeting/meeting_model.dart new file mode 100644 index 0000000..225f633 --- /dev/null +++ b/lib/meeting/meeting_model.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class Meeting { + final int uniqueNo; // uniqueno (Primary Key) + final String meetingTitle; // meeting_title (會議標題) + final String? meetingPlace; // meeting_place (會議地點) + final String? meetingDesc; // meeting_desc (會議說明) + final String? bossPersonId; // boss_personid (主持人 ID) + final String? recPersonId; // rec_personid (記錄人 ID) + final DateTime? startDate; // startdate (開始日期) + final String? startTime; // starttime (開始時間, e.g., '14:00') + final DateTime? endDate; // end_date (結束日期) + final String? endTime; // end_time (結束時間, e.g., '16:00') + final String? resolution; // meeting_resolution (會議決議) + final String? meetingUsers; // meeting_users (參與者名單) + final String? flowFlag; // flowflag (狀態/流程旗標) + + Meeting({ + required this.uniqueNo, + required this.meetingTitle, + this.meetingPlace, + this.meetingDesc, + this.bossPersonId, + this.recPersonId, + this.startDate, + this.startTime, + this.endDate, + this.endTime, + this.resolution, + this.meetingUsers, + this.flowFlag, + }); + + // Factory 構造函數:從 API 返回的 JSON (Map) 創建 Meeting 物件 + factory Meeting.fromJson(Map json) { + DateTime? parseDate(dynamic date) { + if (date is String && date.isNotEmpty) { + // 假設日期格式為 YYYY-MM-DD HH:mm:ss.sss 或 YYYY-MM-DD + return DateTime.tryParse(date); + } + return null; + } + + return Meeting( + uniqueNo: json['uniqueno'] as int? ?? 0, + meetingTitle: json['meeting_title'] as String? ?? '無標題會議', + meetingPlace: json['meeting_place'] as String?, + meetingDesc: json['meeting_desc'] as String?, + bossPersonId: json['boss_personid'] as String?, + recPersonId: json['rec_personid'] as String?, + startDate: parseDate(json['startdate']), + startTime: json['starttime'] as String?, + endDate: parseDate(json['end_date']), + endTime: json['end_time'] as String?, + resolution: json['meeting_resolution'] as String?, + meetingUsers: json['meeting_users'] as String?, + flowFlag: json['flowflag'] as String?, + ); + } + + // Helper: 格式化開始日期和時間 + String get formattedStartDateTime { + if (startDate == null) return 'N/A'; + final datePart = DateFormat('MM/dd').format(startDate!); + final timePart = startTime ?? ''; + return '$datePart ${timePart.isNotEmpty ? timePart : ''}'.trim(); + } + + // Helper: 獲取會議狀態顏色 + Color get statusColor { + // 假設 '1' 表示已定案/已批准, '0' 表示審核中, 其他表示草稿/待開始 + switch (flowFlag) { + case '1': + return Colors.green; // 已定案 + case '0': + return Colors.orange; // 審核中 + default: + return Colors.blue; // 待開始 + } + } + + // Helper: 獲取會議狀態文字 + String get statusText { + switch (flowFlag) { + case '1': + return '已定案'; + case '0': + return '審核中'; + default: + return '待開始'; + } + } +} \ No newline at end of file diff --git a/lib/placeholder_screens.dart b/lib/placeholder_screens.dart new file mode 100644 index 0000000..7389b8b --- /dev/null +++ b/lib/placeholder_screens.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +// 通用的佔位畫面 +class PlaceholderScreen extends StatelessWidget { + final String title; + + const PlaceholderScreen({required this.title, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Text( + '$title 功能正在開發中...', + style: const TextStyle(fontSize: 24, color: Colors.grey), + ), + ), + ); + } +} + +// 各功能頁面 (繼承自 PlaceholderScreen,方便未來替換成真實頁面) +class AttendanceScreen extends PlaceholderScreen { + AttendanceScreen({super.key}) : super(title: '員工打卡'); +} + +class ApprovalScreen extends PlaceholderScreen { + ApprovalScreen({super.key}) : super(title: '待簽核事項'); +} + +class TodoScreen extends PlaceholderScreen { + TodoScreen({super.key}) : super(title: '待辦事項'); +} + +class CalendarScreen extends PlaceholderScreen { + CalendarScreen({super.key}) : super(title: '行事曆'); +} \ No newline at end of file diff --git a/lib/services/generic_api_service.dart b/lib/services/generic_api_service.dart new file mode 100644 index 0000000..bf86796 --- /dev/null +++ b/lib/services/generic_api_service.dart @@ -0,0 +1,84 @@ +import 'package:http/http.dart' as http; +import 'dart:convert'; +import '../auth_manager.dart'; // 確保引入 AuthManager + +// 共用 API 常數 +const String BASE_IP = "https://api.gex.com.tw:8033"; +const String COMMON_API_ENDPOINT = "xapi/v2/eis_demo/orm_api_v2/3/"; +const String COMMON_API_URL = "$BASE_IP/$COMMON_API_ENDPOINT"; + +class GenericApiService { + + /// 通用的獲取列表方法 + /// [T] : 目標資料模型 (例如 Announcement 或 Todo) + /// [tableName] : 資料庫表名 (API 參數) + /// [pk] : 主鍵欄位名稱 (API 參數) + /// [queryFilter] : 查詢過濾條件 (API 參數) + /// [fromJson] : 將 Map 轉換為物件的工廠方法 (例如 Announcement.fromJson) + Future> fetchList({ + required String tableName, + required String pk, + required String queryFilter, + required T Function(Map) fromJson, + Map? additionalParams, // 允許傳入額外參數 (如分頁) + }) async { + + // 1. 讀取 Token + String? token = await AuthManager.getToken(); + if (token == null) { + print("錯誤:未找到登入 Token"); + // 根據需求決定是否拋出錯誤或回傳空陣列 + throw Exception('未登入'); + } + + // 2. 建立 API 參數 + final Map params = { + "token": token, + "_\$_tableName": tableName, + "_\$_pk": pk, + "_\$_action": "P", + "_\$_query_filter": queryFilter, + }; + + // 如果有額外參數,合併進去 + if (additionalParams != null) { + params.addAll(additionalParams); + } + + try { + final response = await http.post( + Uri.parse(COMMON_API_URL), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params, + ); + + if (response.statusCode == 200) { + final Map responseData = json.decode(response.body); + + // 檢查 code + if (responseData['code'] == 0) { + final dynamic dataObject = responseData['data']; + + // 檢查結構是否正確 (data -> items List) + if (dataObject is Map && dataObject['items'] is List) { + List listJson = dataObject['items']; + + // 核心魔法:使用傳入的 fromJson 函數將資料轉換為 T 類型 + return listJson.map((json) => fromJson(json as Map)).toList(); + } else { + return []; // 結構不符或無資料 + } + } else { + throw Exception(responseData['msg'] ?? 'API 邏輯錯誤'); + } + } else { + throw Exception('HTTP 錯誤: ${response.statusCode}'); + } + } catch (e) { + print('API 請求錯誤 ($tableName): $e'); + throw Exception('無法載入資料: $e'); + } + } +} \ No newline at end of file diff --git a/lib/setup/settings_page.dart b/lib/setup/settings_page.dart new file mode 100644 index 0000000..0388620 --- /dev/null +++ b/lib/setup/settings_page.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import '../auth_manager.dart'; // 引入剛才修正的 AuthManager + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + String _currentUserId = '載入中...'; + bool _isNotificationEnabled = true; + + @override + void initState() { + super.initState(); + _loadUserInfo(); + } + + // 從本地儲存載入使用者工號 + Future _loadUserInfo() async { + final userId = await AuthManager.getUserId(); + setState(() { + _currentUserId = userId ?? '未登入'; + }); + } + + // 處理登出邏輯 + void _handleLogout() async { + // 顯示確認對話框 + bool? confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('確認登出'), + content: const Text('您確定要登出系統嗎?登出後將清除本地緩存資訊。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('確定', style: TextStyle(color: Colors.red)) + ), + ], + ), + ); + + if (confirm == true) { + await AuthManager.logout(); + // 導向登入頁面並清空路由棧 + if (mounted) { + Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('系統設定'), + centerTitle: true, + ), + body: ListView( + children: [ + // 區塊一:個人資訊 + _buildSectionHeader('個人帳戶'), + ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: const Text('當前使用者'), + subtitle: Text('工號:$_currentUserId'), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () { /* 導向個人詳細資料 */ }, + ), + + const Divider(), + + // 區塊二:系統設定 + _buildSectionHeader('偏好設定'), + SwitchListTile( + secondary: const Icon(Icons.notifications_active_outlined), + title: const Text('推播通知'), + subtitle: const Text('接收即時會議與 Issue 通知'), + value: _isNotificationEnabled, + onChanged: (bool value) { + setState(() { + _isNotificationEnabled = value; + }); + }, + ), + ListTile( + leading: const Icon(Icons.dark_mode_outlined), + title: const Text('深色模式'), + trailing: const Text('跟隨系統'), + onTap: () { /* 實作切換主題邏輯 */ }, + ), + ListTile( + leading: const Icon(Icons.language), + title: const Text('語系設定'), + trailing: const Text('繁體中文'), + onTap: () { /* 實作切換語言邏輯 */ }, + ), + + const Divider(), + + // 區塊三:關於與支援 + _buildSectionHeader('其他'), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('版本資訊'), + trailing: const Text('v1.0.2'), + ), + ListTile( + leading: const Icon(Icons.help_outline), + title: const Text('使用手冊'), + onTap: () { /* 開啟 PDF 或網頁 */ }, + ), + + const SizedBox(height: 30), + + // 登出按鈕 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ElevatedButton.icon( + onPressed: _handleLogout, + icon: const Icon(Icons.logout), + label: const Text('安全登出'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade50, + foregroundColor: Colors.red, + side: BorderSide(color: Colors.red.shade200), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)) + ), + ), + ), + + const SizedBox(height: 20), + ], + ), + ); + } + + // 輔助函式:建立區塊標題 + Widget _buildSectionHeader(String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/todo/todo_api.dart b/lib/todo/todo_api.dart new file mode 100644 index 0000000..ca53424 --- /dev/null +++ b/lib/todo/todo_api.dart @@ -0,0 +1,22 @@ +import './todo_model.dart'; +import '../services/generic_api_service.dart'; // 引入共用服務 + +class TodoApiService { + final GenericApiService _apiService = GenericApiService(); + final String currentUserId; + + TodoApiService({this.currentUserId = 'admin'}); + + Future> fetchTodos() async { + return await _apiService.fetchList( + tableName: "eip_todolist", + pk: "id", + // 如果 queryFilter 需要動態包含使用者 ID,可以在這裡字串插值 + // 假設原程式碼邏輯是 "1^10^id^*^^pmsm02^^" + queryFilter: "1^10^id^*^^pmsm02^^", + fromJson: (json) => Todo.fromJson(json), + // 如果未來需要傳遞其他參數 (如 userID),可以用 additionalParams + // additionalParams: { "userId": currentUserId }, + ); + } +} \ No newline at end of file diff --git a/lib/todo/todo_detail.dart b/lib/todo/todo_detail.dart new file mode 100644 index 0000000..dd1e7a0 --- /dev/null +++ b/lib/todo/todo_detail.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import './todo_model.dart'; + +class TodoDetail extends StatelessWidget { + final Todo todo; + + const TodoDetail({required this.todo, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(todo.taskName, overflow: TextOverflow.ellipsis), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 任務標題 + Text( + todo.taskName, + style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87), + ), + const Divider(height: 24.0), + + // 任務屬性表格 (簡潔顯示) + _buildAttributeRow(context, '狀態', todo.status ?? 'N/A', todo.statusColor), + _buildAttributeRow(context, '優先級', todo.priority ?? 'N/A', Colors.red), + _buildAttributeRow(context, '截止日期', todo.formattedEndDate, Colors.blue), + _buildAttributeRow(context, '建立者', todo.createdBy ?? 'N/A', Colors.grey), + _buildAttributeRow(context, '分類', todo.className, Colors.purple), + + const Divider(height: 32.0), + + // 詳細說明 + const Text( + '詳細說明:', + style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87), + ), + const SizedBox(height: 8), + Text( + todo.description ?? '無詳細說明。', + style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black54), + textAlign: TextAlign.justify, + ), + const SizedBox(height: 40), + + // 底部按鈕 (例如:標記完成) + Center( + child: ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已標記任務 "${todo.taskName}" 待實作更新狀態。')), + ); + // 實際應用中,這裡會呼叫 API 更新 pbi_status 為 'DONE' + }, + icon: const Icon(Icons.check_circle_outline), + label: const Text('標記為已完成'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), + backgroundColor: Colors.green, + foregroundColor: Colors.white, + textStyle: const TextStyle(fontSize: 18), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAttributeRow(BuildContext context, String label, String value, Color color) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle(fontWeight: FontWeight.w600, color: color), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/todo/todo_manager.dart b/lib/todo/todo_manager.dart new file mode 100644 index 0000000..4d52fee --- /dev/null +++ b/lib/todo/todo_manager.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import './todo_api.dart'; +import './todo_model.dart'; +import './todo_detail.dart'; // 稍後創建 +// import './main.dart'; // 確保可以訪問 MainMenu + +class TodoManager extends StatefulWidget { + // 實際應用中,這裡應該傳入當前用戶 ID + final String currentUserId; + const TodoManager({this.currentUserId = 'admin', super.key}); + + @override + State createState() { + return _TodoManagerState(); + } +} + +class _TodoManagerState extends State { + late TodoApiService _apiService; + late Future> _todosFuture; + + @override + void initState() { + super.initState(); + _apiService = TodoApiService(currentUserId: widget.currentUserId); + // 頁面加載時自動開始獲取資料 + _todosFuture = _apiService.fetchTodos(); + } + + // 刷新資料的函數 + void _refreshTodos() { + setState(() { + _todosFuture = _apiService.fetchTodos(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('我的待辦事項'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: '刷新列表', + onPressed: _refreshTodos, + ), + // 假設這裡使用 Navigator.pop(context) 即可返回 MainMenu + IconButton( + icon: const Icon(Icons.home), + tooltip: '返回主頁', + onPressed: () => Navigator.pop(context), + ), + ], + ), + body: FutureBuilder>( + future: _todosFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton(onPressed: _refreshTodos, child: const Text('重試')), + ], + ), + ); + } else if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return TodoList(todos: snapshot.data!); + } else { + return const Center(child: Text('目前沒有待辦事項。工作很輕鬆!')); + } + }, + ), + ); + } +} + +// ----------------------------------------------------------- +// 列表顯示小部件 (TodoList) +// ----------------------------------------------------------- + +class TodoList extends StatelessWidget { + final List todos; + + const TodoList({required this.todos, super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: todos.length, + itemBuilder: (BuildContext context, int index) { + final item = todos[index]; + + return Card( + elevation: 3, + margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0), + child: InkWell( + onTap: () { + // 點擊項目:導航到詳細頁面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TodoDetail(todo: item), + ), + ); + }, + child: ListTile( + // 左側狀態指示器 + leading: Container( + width: 10, + decoration: BoxDecoration( + color: item.statusColor, + borderRadius: BorderRadius.circular(5), + ), + ), + title: Text( + item.taskName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '分類: ${item.className} | 優先級: ${item.priority ?? '一般'}', + style: const TextStyle(fontSize: 12), + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '截止日', + style: TextStyle(fontSize: 10, color: item.statusColor), + ), + Text( + item.formattedEndDate, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/todo/todo_model.dart b/lib/todo/todo_model.dart new file mode 100644 index 0000000..ff4a0a3 --- /dev/null +++ b/lib/todo/todo_model.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class Todo { + final int id; + final String className; // todolist_class (類別/分類) + final String taskName; // task_name (任務名稱/標題) + final String? description; // task_desc (詳細說明) + final String? priority; // issue_priority (優先級) + final String? status; // pbi_status (狀態) + final DateTime? endDate; // end_date (預計完成日期) + final String? createdBy; // create_user (建立者) + final DateTime? createDate; // create_date (建立日期) + + Todo({ + required this.id, + required this.className, + required this.taskName, + this.description, + this.priority, + this.status, + this.endDate, + this.createdBy, + this.createDate, + }); + + // Factory 構造函數:從 API 返回的 JSON (Map) 創建 Todo 物件 + factory Todo.fromJson(Map json) { + // 輔助函數:安全解析日期字串 + DateTime? parseDate(dynamic date) { + if (date is String && date.isNotEmpty) { + // 假設日期格式為 YYYY-MM-DD HH:mm:ss.sss 或 YYYY-MM-DD + return DateTime.tryParse(date); + } + return null; + } + + return Todo( + id: json['id'] as int? ?? 0, + className: json['todolist_class'] as String? ?? '未分類', + taskName: json['task_name'] as String? ?? '無任務標題', + description: json['task_desc'] as String?, + priority: json['issue_priority'] as String?, + status: json['pbi_status'] as String?, + endDate: parseDate(json['end_date']), + createdBy: json['create_user'] as String?, + createDate: parseDate(json['create_date']), + ); + } + + // 格式化日期,用於列表顯示 + String get formattedEndDate { + if (endDate == null) return 'N/A'; + return DateFormat('yyyy/MM/dd').format(endDate!); + } + + // 根據狀態獲取顏色 (例如:已完成/進行中) + Color get statusColor { + switch (status?.toUpperCase()) { + case 'WIP': // Work In Progress + return Colors.blue; + case 'DONE': // Completed + return Colors.green; + case 'HOLD': // On Hold + return Colors.orange; + default: + return Colors.grey; + } + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..564e017 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,530 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63 + url: "https://pub.dev" + source: hosted + version: "3.1.3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..8bb859c --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,103 @@ +name: learn +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.0 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + # 新增 toast + # fluttertoast: + # 新增 HTTP 請求套件 + http: ^1.1.0 + # 新增 MD5 加密套件 (用於密碼加密) + crypto: ^3.0.3 + # 公告新增 + intl: ^0.19.0 + shared_preferences: ^2.2.2 # 請檢查最新的穩定版本 + url_launcher: ^6.2.2 # 請使用當前最新的穩定版本 + geolocator: ^13.0.1 + table_calendar: ^3.1.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + assets: + - assets/F16-02.jpg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..58c33d4 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:learn/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const ZenApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..97fe8ff --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + learn + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..9f6c048 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "learn", + "short_name": "learn", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}