mirror of
https://github.com/madari-media/madari-oss.git
synced 2026-01-11 22:40:23 +00:00
fix: prerelease
This commit is contained in:
parent
00d643a9fd
commit
50fdce5157
276 changed files with 20339 additions and 27348 deletions
|
|
@ -7,6 +7,11 @@ An open-source media manager app built with Flutter, designed to stream videos f
|
|||
- **Cross-Platform Support**: Works on Android, iOS supported by Flutter.
|
||||
- **Open Source**: Contributions are welcome!
|
||||
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Update dialog
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img src="readme/image/home.jpg" width="250" title="Home Page">
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ dependencies {
|
|||
implementation('com.google.mlkit:text-recognition-devanagari:16.0.1')
|
||||
implementation('com.google.mlkit:text-recognition-chinese:16.0.1')
|
||||
implementation('com.google.mlkit:text-recognition-korean:16.0.1')
|
||||
implementation("androidx.tv:tv-foundation:1.0.0-alpha12")
|
||||
implementation("androidx.tv:tv-material:1.1.0-alpha01")
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
|
|
|||
239
assets/data/regions.json
Normal file
239
assets/data/regions.json
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
{
|
||||
"AF": "Afghanistan",
|
||||
"AL": "Albania",
|
||||
"DZ": "Algeria",
|
||||
"AS": "American Samoa",
|
||||
"AD": "Andorra",
|
||||
"AO": "Angola",
|
||||
"AI": "Anguilla",
|
||||
"AQ": "Antarctica",
|
||||
"AG": "Antigua & Barbuda",
|
||||
"AR": "Argentina",
|
||||
"AM": "Armenia",
|
||||
"AW": "Aruba",
|
||||
"AU": "Australia",
|
||||
"AT": "Austria",
|
||||
"AZ": "Azerbaijan",
|
||||
"BS": "Bahamas",
|
||||
"BH": "Bahrain",
|
||||
"BD": "Bangladesh",
|
||||
"BB": "Barbados",
|
||||
"BY": "Belarus",
|
||||
"BE": "Belgium",
|
||||
"BZ": "Belize",
|
||||
"BJ": "Benin",
|
||||
"BM": "Bermuda",
|
||||
"BT": "Bhutan",
|
||||
"BO": "Bolivia",
|
||||
"BA": "Bosnia & Herzegovina",
|
||||
"BW": "Botswana",
|
||||
"BV": "Bouvet Island",
|
||||
"BR": "Brazil",
|
||||
"IO": "British Indian Ocean Territory",
|
||||
"BN": "Brunei Darussalam",
|
||||
"BG": "Bulgaria",
|
||||
"BF": "Burkina Faso",
|
||||
"BI": "Burundi",
|
||||
"KH": "Cambodia",
|
||||
"CM": "Cameroon",
|
||||
"CA": "Canada",
|
||||
"CV": "Cape Verde",
|
||||
"KY": "Cayman Islands",
|
||||
"CF": "Central African Republic",
|
||||
"TD": "Chad",
|
||||
"CL": "Chile",
|
||||
"CN": "China",
|
||||
"CX": "Christmas Island",
|
||||
"CC": "Cocos (Keeling) Islands",
|
||||
"CO": "Colombia",
|
||||
"KM": "Comoros",
|
||||
"CG": "Congo",
|
||||
"CD": "Zaire",
|
||||
"CK": "Cook Islands",
|
||||
"CR": "Costa Rica",
|
||||
"CI": "Cote D'ivoire (Ivory Coast)",
|
||||
"HR": "Croatia (Hrvatska)",
|
||||
"CU": "Cuba",
|
||||
"CY": "Cyprus",
|
||||
"CZ": "Czech Republic",
|
||||
"DK": "Denmark",
|
||||
"DJ": "Djibouti",
|
||||
"DM": "Dominica",
|
||||
"DO": "Dominican Republic",
|
||||
"TP": "East Timor",
|
||||
"EC": "Ecuador",
|
||||
"EG": "Egypt",
|
||||
"SV": "El Salvador",
|
||||
"GQ": "Equatorial Guinea",
|
||||
"ER": "Eritrea",
|
||||
"EE": "Estonia",
|
||||
"ET": "Ethiopia",
|
||||
"FK": "Falkland Islands (Malvinas)",
|
||||
"FO": "Faroe Islands",
|
||||
"FJ": "Fiji",
|
||||
"FI": "Finland",
|
||||
"FR": "France",
|
||||
"GF": "French Guiana",
|
||||
"PF": "French Polynesia",
|
||||
"TF": "French Southern Territories",
|
||||
"GA": "Gabon",
|
||||
"GM": "Gambia",
|
||||
"GE": "Georgia",
|
||||
"DE": "Germany",
|
||||
"GH": "Ghana",
|
||||
"GI": "Gibraltar",
|
||||
"GB": "United Kingdom",
|
||||
"GR": "Greece",
|
||||
"GL": "Greenland",
|
||||
"GD": "Grenada",
|
||||
"GP": "Guadeloupe",
|
||||
"GU": "Guam",
|
||||
"GT": "Guatemala",
|
||||
"GN": "Guinea",
|
||||
"GW": "Guinea-Bissau",
|
||||
"GY": "Guyana",
|
||||
"HT": "Haiti",
|
||||
"HM": "Heard & McDonald Islands",
|
||||
"VA": "Vatican City (Holy See)",
|
||||
"HN": "Honduras",
|
||||
"HK": "Hong Kong",
|
||||
"HU": "Hungary",
|
||||
"IS": "Iceland",
|
||||
"IN": "India",
|
||||
"ID": "Indonesia",
|
||||
"IR": "Iran",
|
||||
"IQ": "Iraq",
|
||||
"IE": "Ireland",
|
||||
"IL": "Israel",
|
||||
"IT": "Italy",
|
||||
"JM": "Jamaica",
|
||||
"JP": "Japan",
|
||||
"JO": "Jordan",
|
||||
"KZ": "Kazakhstan",
|
||||
"KE": "Kenya",
|
||||
"KI": "Kiribati",
|
||||
"KP": "Korea (North)",
|
||||
"KR": "Korea (South)",
|
||||
"KW": "Kuwait",
|
||||
"KG": "Kyrgyzstan",
|
||||
"LA": "Laos",
|
||||
"LV": "Latvia",
|
||||
"LB": "Lebanon",
|
||||
"LS": "Lesotho",
|
||||
"LR": "Liberia",
|
||||
"LY": "Libya",
|
||||
"LI": "Liechtenstein",
|
||||
"LT": "Lithuania",
|
||||
"LU": "Luxembourg",
|
||||
"MO": "Macau",
|
||||
"MK": "Macedonia",
|
||||
"MG": "Madagascar",
|
||||
"MW": "Malawi",
|
||||
"MY": "Malaysia",
|
||||
"MV": "Maldives",
|
||||
"ML": "Mali",
|
||||
"MT": "Malta",
|
||||
"MH": "Marshall Islands",
|
||||
"MQ": "Martinique",
|
||||
"MR": "Mauritania",
|
||||
"MU": "Mauritius",
|
||||
"YT": "Mayotte",
|
||||
"MX": "Mexico",
|
||||
"FM": "Micronesia",
|
||||
"MD": "Moldova",
|
||||
"MC": "Monaco",
|
||||
"MN": "Mongolia",
|
||||
"MS": "Montserrat",
|
||||
"MA": "Morocco",
|
||||
"MZ": "Mozambique",
|
||||
"MM": "Myanmar",
|
||||
"NA": "Namibia",
|
||||
"NR": "Nauru",
|
||||
"NP": "Nepal",
|
||||
"NL": "Netherlands",
|
||||
"AN": "Netherlands Antilles",
|
||||
"NC": "New Caledonia",
|
||||
"NZ": "New Zealand",
|
||||
"NI": "Nicaragua",
|
||||
"NE": "Niger",
|
||||
"NG": "Nigeria",
|
||||
"NU": "Niue",
|
||||
"NF": "Norfolk Island",
|
||||
"MP": "Northern Mariana Islands",
|
||||
"NO": "Norway",
|
||||
"OM": "Oman",
|
||||
"PK": "Pakistan",
|
||||
"PW": "Palau",
|
||||
"PA": "Panama",
|
||||
"PG": "Papua New Guinea",
|
||||
"PY": "Paraguay",
|
||||
"PE": "Peru",
|
||||
"PH": "Philippines",
|
||||
"PN": "Pitcairn",
|
||||
"PL": "Poland",
|
||||
"PT": "Portugal",
|
||||
"PR": "Puerto Rico",
|
||||
"QA": "Qatar",
|
||||
"RE": "Reunion",
|
||||
"RO": "Romania",
|
||||
"RU": "Russian Federation",
|
||||
"RW": "Rwanda",
|
||||
"SH": "St. Helena",
|
||||
"KN": "Saint Kitts & Nevis",
|
||||
"LC": "Saint Lucia",
|
||||
"PM": "St. Pierre & Miquelon",
|
||||
"VC": "St. Vincent & the Grenadines",
|
||||
"WS": "Samoa",
|
||||
"SM": "San Marino",
|
||||
"ST": "Sao Tome & Principe",
|
||||
"SA": "Saudi Arabia",
|
||||
"SN": "Senegal",
|
||||
"SC": "Seychelles",
|
||||
"SL": "Sierra Leone",
|
||||
"SG": "Singapore",
|
||||
"SK": "Slovak Republic",
|
||||
"SI": "Slovenia",
|
||||
"SB": "Solomon Islands",
|
||||
"SO": "Somalia",
|
||||
"ZA": "South Africa",
|
||||
"GS": "S.Georgia & S.Sandwich Islands",
|
||||
"ES": "Spain",
|
||||
"LK": "Sri Lanka",
|
||||
"SD": "Sudan",
|
||||
"SR": "Suriname",
|
||||
"SJ": "Svalbard & Jan Mayen Islands",
|
||||
"SZ": "Swaziland",
|
||||
"SE": "Sweden",
|
||||
"CH": "Switzerland",
|
||||
"SY": "Syria",
|
||||
"TW": "Taiwan",
|
||||
"TJ": "Tajikistan",
|
||||
"TZ": "Tanzania",
|
||||
"TH": "Thailand",
|
||||
"TG": "Togo",
|
||||
"TK": "Tokelau",
|
||||
"TO": "Tonga",
|
||||
"TT": "Trinidad & Tobago",
|
||||
"TN": "Tunisia",
|
||||
"TR": "Turkey",
|
||||
"TM": "Turkmenistan",
|
||||
"TC": "Turks & Caicos Islands",
|
||||
"TV": "Tuvalu",
|
||||
"UG": "Uganda",
|
||||
"UA": "Ukraine",
|
||||
"AE": "United Arab Emirates",
|
||||
"US": "United States",
|
||||
"UY": "Uruguay",
|
||||
"UZ": "Uzbekistan",
|
||||
"VU": "Vanuatu",
|
||||
"VE": "Venezuela",
|
||||
"VN": "Viet Nam",
|
||||
"VG": "Virgin Islands (British)",
|
||||
"VI": "Virgin Islands (U.S.)",
|
||||
"WF": "Wallis & Futuna Islands",
|
||||
"EH": "Western Sahara",
|
||||
"YE": "Yemen",
|
||||
"YU": "Yugoslavia",
|
||||
"ZM": "Zambia",
|
||||
"ZW": "Zimbabwe"
|
||||
}
|
||||
937
assets/data/tmdb_language.json
Normal file
937
assets/data/tmdb_language.json
Normal file
|
|
@ -0,0 +1,937 @@
|
|||
[
|
||||
{
|
||||
"iso_639_1": "xx",
|
||||
"english_name": "No Language",
|
||||
"name": "No Language"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "aa",
|
||||
"english_name": "Afar",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "af",
|
||||
"english_name": "Afrikaans",
|
||||
"name": "Afrikaans"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ak",
|
||||
"english_name": "Akan",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "an",
|
||||
"english_name": "Aragonese",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "as",
|
||||
"english_name": "Assamese",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "av",
|
||||
"english_name": "Avaric",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ae",
|
||||
"english_name": "Avestan",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ay",
|
||||
"english_name": "Aymara",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "az",
|
||||
"english_name": "Azerbaijani",
|
||||
"name": "Azərbaycan"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ba",
|
||||
"english_name": "Bashkir",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "bm",
|
||||
"english_name": "Bambara",
|
||||
"name": "Bamanankan"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "bn",
|
||||
"english_name": "Bengali",
|
||||
"name": "বাংলা"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "bi",
|
||||
"english_name": "Bislama",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "bo",
|
||||
"english_name": "Tibetan",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "bs",
|
||||
"english_name": "Bosnian",
|
||||
"name": "Bosanski"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "br",
|
||||
"english_name": "Breton",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ca",
|
||||
"english_name": "Catalan",
|
||||
"name": "Català"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "cs",
|
||||
"english_name": "Czech",
|
||||
"name": "Český"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ch",
|
||||
"english_name": "Chamorro",
|
||||
"name": "Finu' Chamorro"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ce",
|
||||
"english_name": "Chechen",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "cu",
|
||||
"english_name": "Slavic",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "cv",
|
||||
"english_name": "Chuvash",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "kw",
|
||||
"english_name": "Cornish",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "co",
|
||||
"english_name": "Corsican",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "cr",
|
||||
"english_name": "Cree",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "cy",
|
||||
"english_name": "Welsh",
|
||||
"name": "Cymraeg"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "da",
|
||||
"english_name": "Danish",
|
||||
"name": "Dansk"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "de",
|
||||
"english_name": "German",
|
||||
"name": "Deutsch"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "dv",
|
||||
"english_name": "Divehi",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "dz",
|
||||
"english_name": "Dzongkha",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "en",
|
||||
"english_name": "English",
|
||||
"name": "English"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "eo",
|
||||
"english_name": "Esperanto",
|
||||
"name": "Esperanto"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "et",
|
||||
"english_name": "Estonian",
|
||||
"name": "Eesti"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "eu",
|
||||
"english_name": "Basque",
|
||||
"name": "euskera"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "fo",
|
||||
"english_name": "Faroese",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "fj",
|
||||
"english_name": "Fijian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "fi",
|
||||
"english_name": "Finnish",
|
||||
"name": "suomi"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "fr",
|
||||
"english_name": "French",
|
||||
"name": "Français"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "fy",
|
||||
"english_name": "Frisian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ff",
|
||||
"english_name": "Fulah",
|
||||
"name": "Fulfulde"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "gd",
|
||||
"english_name": "Gaelic",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ga",
|
||||
"english_name": "Irish",
|
||||
"name": "Gaeilge"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "gl",
|
||||
"english_name": "Galician",
|
||||
"name": "Galego"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "gv",
|
||||
"english_name": "Manx",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "gn",
|
||||
"english_name": "Guarani",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "gu",
|
||||
"english_name": "Gujarati",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ht",
|
||||
"english_name": "Haitian; Haitian Creole",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ha",
|
||||
"english_name": "Hausa",
|
||||
"name": "Hausa"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sh",
|
||||
"english_name": "Serbo-Croatian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "hz",
|
||||
"english_name": "Herero",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ho",
|
||||
"english_name": "Hiri Motu",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "hr",
|
||||
"english_name": "Croatian",
|
||||
"name": "Hrvatski"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "hu",
|
||||
"english_name": "Hungarian",
|
||||
"name": "Magyar"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ig",
|
||||
"english_name": "Igbo",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "io",
|
||||
"english_name": "Ido",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ii",
|
||||
"english_name": "Yi",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "iu",
|
||||
"english_name": "Inuktitut",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ie",
|
||||
"english_name": "Interlingue",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ia",
|
||||
"english_name": "Interlingua",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "id",
|
||||
"english_name": "Indonesian",
|
||||
"name": "Bahasa indonesia"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ik",
|
||||
"english_name": "Inupiaq",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "is",
|
||||
"english_name": "Icelandic",
|
||||
"name": "Íslenska"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "it",
|
||||
"english_name": "Italian",
|
||||
"name": "Italiano"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "jv",
|
||||
"english_name": "Javanese",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ja",
|
||||
"english_name": "Japanese",
|
||||
"name": "日本語"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "kl",
|
||||
"english_name": "Kalaallisut",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "kn",
|
||||
"english_name": "Kannada",
|
||||
"name": "?????"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ks",
|
||||
"english_name": "Kashmiri",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ka",
|
||||
"english_name": "Georgian",
|
||||
"name": "ქართული"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "kr",
|
||||
"english_name": "Kanuri",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "kk",
|
||||
"english_name": "Kazakh",
|
||||
"name": "қазақ"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "km",
|
||||
"english_name": "Khmer",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ki",
|
||||
"english_name": "Kikuyu",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "rw",
|
||||
"english_name": "Kinyarwanda",
|
||||
"name": "Kinyarwanda"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ky",
|
||||
"english_name": "Kirghiz",
|
||||
"name": "??????"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "kv",
|
||||
"english_name": "Komi",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "kg",
|
||||
"english_name": "Kongo",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ko",
|
||||
"english_name": "Korean",
|
||||
"name": "한국어/조선말"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "kj",
|
||||
"english_name": "Kuanyama",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ku",
|
||||
"english_name": "Kurdish",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "lo",
|
||||
"english_name": "Lao",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "la",
|
||||
"english_name": "Latin",
|
||||
"name": "Latin"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "lv",
|
||||
"english_name": "Latvian",
|
||||
"name": "Latviešu"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "li",
|
||||
"english_name": "Limburgish",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ln",
|
||||
"english_name": "Lingala",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "lt",
|
||||
"english_name": "Lithuanian",
|
||||
"name": "Lietuvių"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "lb",
|
||||
"english_name": "Letzeburgesch",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "lu",
|
||||
"english_name": "Luba-Katanga",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "lg",
|
||||
"english_name": "Ganda",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "mh",
|
||||
"english_name": "Marshall",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ml",
|
||||
"english_name": "Malayalam",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "mr",
|
||||
"english_name": "Marathi",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "mg",
|
||||
"english_name": "Malagasy",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "mt",
|
||||
"english_name": "Maltese",
|
||||
"name": "Malti"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "mo",
|
||||
"english_name": "Moldavian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "mn",
|
||||
"english_name": "Mongolian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "mi",
|
||||
"english_name": "Maori",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ms",
|
||||
"english_name": "Malay",
|
||||
"name": "Bahasa melayu"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "my",
|
||||
"english_name": "Burmese",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "na",
|
||||
"english_name": "Nauru",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "nv",
|
||||
"english_name": "Navajo",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "nr",
|
||||
"english_name": "Ndebele",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "nd",
|
||||
"english_name": "Ndebele",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ng",
|
||||
"english_name": "Ndonga",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ne",
|
||||
"english_name": "Nepali",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "nl",
|
||||
"english_name": "Dutch",
|
||||
"name": "Nederlands"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "nn",
|
||||
"english_name": "Norwegian Nynorsk",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "nb",
|
||||
"english_name": "Norwegian Bokmål",
|
||||
"name": "Bokmål"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "no",
|
||||
"english_name": "Norwegian",
|
||||
"name": "Norsk"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ny",
|
||||
"english_name": "Chichewa; Nyanja",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "oc",
|
||||
"english_name": "Occitan",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "oj",
|
||||
"english_name": "Ojibwa",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "or",
|
||||
"english_name": "Oriya",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "om",
|
||||
"english_name": "Oromo",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "os",
|
||||
"english_name": "Ossetian; Ossetic",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "pa",
|
||||
"english_name": "Punjabi",
|
||||
"name": "ਪੰਜਾਬੀ"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "pi",
|
||||
"english_name": "Pali",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "pl",
|
||||
"english_name": "Polish",
|
||||
"name": "Polski"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "pt",
|
||||
"english_name": "Portuguese",
|
||||
"name": "Português"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "qu",
|
||||
"english_name": "Quechua",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "rm",
|
||||
"english_name": "Raeto-Romance",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ro",
|
||||
"english_name": "Romanian",
|
||||
"name": "Română"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "rn",
|
||||
"english_name": "Rundi",
|
||||
"name": "Kirundi"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ru",
|
||||
"english_name": "Russian",
|
||||
"name": "Pусский"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sg",
|
||||
"english_name": "Sango",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sa",
|
||||
"english_name": "Sanskrit",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "si",
|
||||
"english_name": "Sinhalese",
|
||||
"name": "සිංහල"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sk",
|
||||
"english_name": "Slovak",
|
||||
"name": "Slovenčina"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sl",
|
||||
"english_name": "Slovenian",
|
||||
"name": "Slovenščina"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "se",
|
||||
"english_name": "Northern Sami",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sm",
|
||||
"english_name": "Samoan",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sn",
|
||||
"english_name": "Shona",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sd",
|
||||
"english_name": "Sindhi",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "so",
|
||||
"english_name": "Somali",
|
||||
"name": "Somali"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "st",
|
||||
"english_name": "Sotho",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "es",
|
||||
"english_name": "Spanish",
|
||||
"name": "Español"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sq",
|
||||
"english_name": "Albanian",
|
||||
"name": "shqip"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sc",
|
||||
"english_name": "Sardinian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sr",
|
||||
"english_name": "Serbian",
|
||||
"name": "Srpski"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ss",
|
||||
"english_name": "Swati",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "su",
|
||||
"english_name": "Sundanese",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sw",
|
||||
"english_name": "Swahili",
|
||||
"name": "Kiswahili"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "sv",
|
||||
"english_name": "Swedish",
|
||||
"name": "svenska"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ty",
|
||||
"english_name": "Tahitian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ta",
|
||||
"english_name": "Tamil",
|
||||
"name": "தமிழ்"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "tt",
|
||||
"english_name": "Tatar",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "te",
|
||||
"english_name": "Telugu",
|
||||
"name": "తెలుగు"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "tg",
|
||||
"english_name": "Tajik",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "tl",
|
||||
"english_name": "Tagalog",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "th",
|
||||
"english_name": "Thai",
|
||||
"name": "ภาษาไทย"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ti",
|
||||
"english_name": "Tigrinya",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "to",
|
||||
"english_name": "Tonga",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "tn",
|
||||
"english_name": "Tswana",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ts",
|
||||
"english_name": "Tsonga",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "tk",
|
||||
"english_name": "Turkmen",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "tr",
|
||||
"english_name": "Turkish",
|
||||
"name": "Türkçe"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "tw",
|
||||
"english_name": "Twi",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ug",
|
||||
"english_name": "Uighur",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "uk",
|
||||
"english_name": "Ukrainian",
|
||||
"name": "Український"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ur",
|
||||
"english_name": "Urdu",
|
||||
"name": "اردو"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "uz",
|
||||
"english_name": "Uzbek",
|
||||
"name": "ozbek"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ve",
|
||||
"english_name": "Venda",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "vi",
|
||||
"english_name": "Vietnamese",
|
||||
"name": "Tiếng Việt"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "vo",
|
||||
"english_name": "Volapük",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "wa",
|
||||
"english_name": "Walloon",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "wo",
|
||||
"english_name": "Wolof",
|
||||
"name": "Wolof"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "xh",
|
||||
"english_name": "Xhosa",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "yi",
|
||||
"english_name": "Yiddish",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "za",
|
||||
"english_name": "Zhuang",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "zu",
|
||||
"english_name": "Zulu",
|
||||
"name": "isiZulu"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ab",
|
||||
"english_name": "Abkhazian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "zh",
|
||||
"english_name": "Mandarin",
|
||||
"name": "普通话"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ps",
|
||||
"english_name": "Pushto",
|
||||
"name": "پښتو"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "am",
|
||||
"english_name": "Amharic",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ar",
|
||||
"english_name": "Arabic",
|
||||
"name": "العربية"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "be",
|
||||
"english_name": "Belarusian",
|
||||
"name": "беларуская мова"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "bg",
|
||||
"english_name": "Bulgarian",
|
||||
"name": "български език"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "cn",
|
||||
"english_name": "Cantonese",
|
||||
"name": "广州话 / 廣州話"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "mk",
|
||||
"english_name": "Macedonian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "ee",
|
||||
"english_name": "Ewe",
|
||||
"name": "Èʋegbe"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "el",
|
||||
"english_name": "Greek",
|
||||
"name": "ελληνικά"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "fa",
|
||||
"english_name": "Persian",
|
||||
"name": "فارسی"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "he",
|
||||
"english_name": "Hebrew",
|
||||
"name": "עִבְרִית"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "hi",
|
||||
"english_name": "Hindi",
|
||||
"name": "हिन्दी"
|
||||
},
|
||||
{
|
||||
"iso_639_1": "hy",
|
||||
"english_name": "Armenian",
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"iso_639_1": "yo",
|
||||
"english_name": "Yoruba",
|
||||
"name": "Èdè Yorùbá"
|
||||
}
|
||||
]
|
||||
|
|
@ -7,8 +7,6 @@ PODS:
|
|||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
- DKImagePickerController/ImageDataManager
|
||||
- DKImagePickerController/Resource
|
||||
|
|
@ -135,9 +133,6 @@ PODS:
|
|||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- pdfrx (0.0.3):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
|
|
@ -181,7 +176,6 @@ DEPENDENCIES:
|
|||
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||
|
|
@ -195,7 +189,6 @@ DEPENDENCIES:
|
|||
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- pdfrx (from `.symlinks/plugins/pdfrx/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
|
|
@ -235,8 +228,6 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
file_picker:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
Flutter:
|
||||
|
|
@ -255,8 +246,6 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
pdfrx:
|
||||
:path: ".symlinks/plugins/pdfrx/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
shared_preferences_foundation:
|
||||
|
|
@ -276,7 +265,6 @@ SPEC CHECKSUMS:
|
|||
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
|
||||
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||
connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
|
|
@ -303,7 +291,6 @@ SPEC CHECKSUMS:
|
|||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
pdfrx: 07fc287c47ea8d027c4ed56457f8a1aa74d73594
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
|
||||
|
|
|
|||
55
lib/app/app.dart
Normal file
55
lib/app/app.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart';
|
||||
import 'package:madari_client/features/video_player/container/state/video_settings.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../features/settings/service/selected_profile.dart';
|
||||
import '../features/theme/provider/theme_provider.dart';
|
||||
import 'app_router.dart';
|
||||
|
||||
class AppDefault extends StatefulWidget {
|
||||
const AppDefault({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppDefault> createState() => _AppDefaultState();
|
||||
}
|
||||
|
||||
class _AppDefaultState extends State<AppDefault> {
|
||||
late GoRouter _router;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_router = createRouterDesktop();
|
||||
|
||||
if (AppPocketBaseService.instance.pb.authStore.isValid) {
|
||||
SelectedProfileService.instance.initialize();
|
||||
}
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
final theme = themeProvider.getTheme();
|
||||
|
||||
return ChangeNotifierProvider(
|
||||
create: (context) => VideoSettingsProvider(),
|
||||
child: MaterialApp.router(
|
||||
routerConfig: _router,
|
||||
title: "Madari",
|
||||
theme: theme.copyWith(
|
||||
textTheme: GoogleFonts.exo2TextTheme(theme.textTheme),
|
||||
),
|
||||
debugShowCheckedModeBanner: false, // comes in the way of the search
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
239
lib/app/app_router.dart
Normal file
239
lib/app/app_router.dart
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:madari_client/features/downloads/pages/downloads_page.dart';
|
||||
import 'package:madari_client/features/offline_ratings/pages/offline_ratings.dart';
|
||||
import 'package:madari_client/features/settings/pages/profile_page.dart';
|
||||
|
||||
import '../features/accounts/pages/external_account.dart';
|
||||
import '../features/auth/pages/forget_password_page.dart';
|
||||
import '../features/auth/pages/signin_page.dart';
|
||||
import '../features/auth/pages/signup_page.dart';
|
||||
import '../features/explore/pages/explore.page.dart';
|
||||
import '../features/home/pages/home_page.dart';
|
||||
import '../features/layout/widgets/scaffold_with_nav.dart';
|
||||
import '../features/library/container/create_list_widget.dart';
|
||||
import '../features/library/pages/library.page.dart';
|
||||
import '../features/library/pages/list_detail_page.dart';
|
||||
import '../features/library/types/library_types.dart';
|
||||
import '../features/pocketbase/service/pocketbase.service.dart';
|
||||
import '../features/settings/pages/appearance_page.dart';
|
||||
import '../features/settings/pages/change_password_page.dart';
|
||||
import '../features/settings/pages/debug/logs_page.dart';
|
||||
import '../features/settings/pages/full_profile_selector.dart';
|
||||
import '../features/settings/pages/layout_page.dart';
|
||||
import '../features/settings/pages/playback_settings_page.dart';
|
||||
import '../features/settings/pages/settings_page.dart';
|
||||
import '../features/settings/pages/subprofiles_page.dart';
|
||||
import '../features/streamio_addons/pages/stremio_addons_page.dart';
|
||||
import '../features/video_player/container/video_player.dart';
|
||||
import '../features/widgetter/plugins/stremio/pages/streamio_item_viewer.dart';
|
||||
import '../features/zeku/pages/integration_page.dart';
|
||||
|
||||
final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final GlobalKey<StatefulNavigationShellState> _shellNavigatorKey =
|
||||
GlobalKey<StatefulNavigationShellState>();
|
||||
|
||||
final GlobalKey<NavigatorState> _homeNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final GlobalKey<NavigatorState> _searchNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
final GlobalKey<NavigatorState> _downloadsNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
final GlobalKey<NavigatorState> _settingsNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
final GlobalKey<NavigatorState> _exploreNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
GoRouter createRouterDesktop() {
|
||||
return GoRouter(
|
||||
navigatorKey: _rootNavigatorKey,
|
||||
initialLocation: '/',
|
||||
refreshListenable: ValueNotifier(
|
||||
AppPocketBaseService.instance.pb.authStore.onChange,
|
||||
),
|
||||
redirect: (context, state) {
|
||||
final isLoggedIn = AppPocketBaseService.instance.pb.authStore.isValid;
|
||||
final isAuthRoute = state.uri.path == '/signin' ||
|
||||
state.uri.path == '/signup' ||
|
||||
state.uri.path == '/forgot-password';
|
||||
|
||||
if (!isLoggedIn && !isAuthRoute) {
|
||||
return '/signin';
|
||||
}
|
||||
|
||||
if (isLoggedIn && isAuthRoute) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/signin',
|
||||
builder: (context, state) => const SignInPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/forgot-password',
|
||||
builder: (context, state) => const ForgotPasswordPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/signup',
|
||||
builder: (context, state) => const SignUpPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/downloads",
|
||||
builder: (context, state) => const DownloadsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/settings/integration",
|
||||
builder: (context, state) => const IntegrationPage(),
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
key: _shellNavigatorKey,
|
||||
builder: (context, state, navigationShell) {
|
||||
return ScaffoldWithNav(
|
||||
child: navigationShell,
|
||||
);
|
||||
},
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _homeNavigatorKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _searchNavigatorKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
builder: (context, state) => const HomePage(
|
||||
hasSearch: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _exploreNavigatorKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/explore',
|
||||
builder: (context, state) => const ExplorePage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _downloadsNavigatorKey,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/library',
|
||||
builder: (context, state) => const LibraryPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: _settingsNavigatorKey,
|
||||
routes: settingsRoutes,
|
||||
)
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: "/layout",
|
||||
builder: (context, state) => const LayoutPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/library/create',
|
||||
builder: (context, state) => const CreateListPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/library/:id',
|
||||
builder: (context, state) {
|
||||
final list = state.extra as ListModel;
|
||||
return ListDetailsPage(list: list);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/profile",
|
||||
builder: (context, state) {
|
||||
return const FullProfileSelectorPage();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/profile/manage",
|
||||
builder: (context, state) => const SubprofilesPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/meta/:type/:id',
|
||||
builder: (context, state) {
|
||||
return StreamioItemViewer(
|
||||
id: state.pathParameters['id']!,
|
||||
type: state.pathParameters['type']!,
|
||||
image: state.uri.queryParameters["image"],
|
||||
name: state.uri.queryParameters['name'],
|
||||
prefix: state.uri.queryParameters['prefix'],
|
||||
meta: state.extra is Map ? (state.extra as Map)["meta"] : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/player/:type/:id/:stream',
|
||||
builder: (context, state) => VideoPlayer(
|
||||
id: state.pathParameters['id']!,
|
||||
type: state.pathParameters['type']!,
|
||||
stream: state.pathParameters["stream"]!,
|
||||
selectedIndex: state.uri.queryParameters["index"],
|
||||
meta: state.extra is Map ? (state.extra as Map)["meta"] : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/settings/offline-ratings",
|
||||
builder: (context, state) => const OfflineRatings(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/addons',
|
||||
builder: (context, state) => const StremioAddonsPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final List<RouteBase> settingsRoutes = [
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/profile',
|
||||
builder: (context, state) => const ProfilePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/appearance',
|
||||
builder: (context, state) => const AppearancePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/stremio',
|
||||
builder: (context, state) => const StremioAddonsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/playback',
|
||||
builder: (context, state) => const PlaybackSettingsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/external-account',
|
||||
builder: (context, state) => const ExternalAccount(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/debug',
|
||||
builder: (context, state) => const LogsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/settings/security",
|
||||
builder: (context, state) => const ChangePasswordPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/settings/subprofiles",
|
||||
builder: (context, state) => const SubprofilesPage(),
|
||||
),
|
||||
];
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class SeasonSelector extends StatelessWidget {
|
||||
const SeasonSelector({
|
||||
class AppWeb extends StatelessWidget {
|
||||
const AppWeb({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container();
|
||||
return const MaterialApp();
|
||||
}
|
||||
}
|
||||
19
lib/consts/data.dart
Normal file
19
lib/consts/data.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const List<DefaultAddon> defaultAppAddons = [
|
||||
DefaultAddon(
|
||||
icon: "https://downloads.madari.media/icon.png",
|
||||
title: "Madari Catalog",
|
||||
url: "https://catalog.madari.media/manifest.json",
|
||||
),
|
||||
];
|
||||
|
||||
class DefaultAddon {
|
||||
final String title;
|
||||
final String icon;
|
||||
final String url;
|
||||
|
||||
const DefaultAddon({
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
55
lib/data/db.dart
Normal file
55
lib/data/db.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
|
||||
import 'tables/ratings.dart';
|
||||
|
||||
part 'db.g.dart';
|
||||
|
||||
@DriftDatabase(tables: [RatingTable])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase._() : super(_openConnection());
|
||||
|
||||
static AppDatabase? _instance;
|
||||
|
||||
factory AppDatabase() {
|
||||
_instance ??= AppDatabase._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<double?> getRatingByTConst(String tconst) async {
|
||||
try {
|
||||
final query = select(ratingTable)
|
||||
..where((tbl) => tbl.tconst.equals(tconst));
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
return result?.averageRating;
|
||||
} catch (e) {
|
||||
print('Error fetching IMDb rating: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
static QueryExecutor _openConnection() {
|
||||
return driftDatabase(
|
||||
name: 'app_db',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
try {
|
||||
await super.close();
|
||||
_instance = null;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to close database connection: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static void clearInstance() {
|
||||
_instance?.close();
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
10
lib/data/tables/ratings.dart
Normal file
10
lib/data/tables/ratings.dart
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
class RatingTable extends Table {
|
||||
TextColumn get tconst => text()();
|
||||
RealColumn get averageRating => real()();
|
||||
IntColumn get numVotes => integer()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {tconst};
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:madari_client/database/quries/watch_history_queries.dart';
|
||||
import 'package:madari_client/database/tables/watch_history_table.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
@DriftDatabase(tables: [
|
||||
WatchHistoryTable,
|
||||
], queries: {}, daos: [
|
||||
WatchHistoryQueries,
|
||||
])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
static QueryExecutor _openConnection() {
|
||||
return driftDatabase(
|
||||
name: 'madari_db',
|
||||
web: DriftWebOptions(
|
||||
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
|
||||
driftWorker: Uri.parse(
|
||||
'assets/assets/ignore_this_error_drift_worker.dart.js',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import 'package:madari_client/database/app_database.dart';
|
||||
|
||||
class DatabaseProvider {
|
||||
late final AppDatabase database;
|
||||
|
||||
DatabaseProvider() {
|
||||
database = AppDatabase();
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../app_database.dart';
|
||||
import '../tables/watch_history_table.dart';
|
||||
|
||||
part 'watch_history_queries.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [WatchHistoryTable])
|
||||
class WatchHistoryQueries extends DatabaseAccessor<AppDatabase>
|
||||
with _$WatchHistoryQueriesMixin {
|
||||
WatchHistoryQueries(super.db);
|
||||
|
||||
Future<List<WatchHistoryTableData>> getWatchHistoryByIds(List<String> ids) {
|
||||
return (select(watchHistoryTable)..where((t) => t.id.isIn(ids))).get();
|
||||
}
|
||||
|
||||
Future<List<WatchHistoryTableData>> getUnsyncedRecords() {
|
||||
return (select(watchHistoryTable)
|
||||
..where((t) =>
|
||||
t.lastSyncedAt.isNull() |
|
||||
t.updatedAt.isBiggerThan(t.lastSyncedAt)))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<void> insertOrUpdateWatchHistory(WatchHistoryTableCompanion entry) {
|
||||
return into(watchHistoryTable).insertOnConflictUpdate(entry);
|
||||
}
|
||||
|
||||
Future<void> updateSyncStatus(String id, DateTime syncTime) {
|
||||
return (update(watchHistoryTable)..where((t) => t.id.equals(id)))
|
||||
.write(WatchHistoryTableCompanion(lastSyncedAt: Value(syncTime)));
|
||||
}
|
||||
|
||||
Future<WatchHistoryTableData?> getWatchHistoryById(String id) {
|
||||
return (select(watchHistoryTable)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> clearWatchHistory() async {
|
||||
await delete(watchHistoryTable).go();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
class WatchHistoryTable extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get originalId => text()();
|
||||
TextColumn get season => text().nullable()();
|
||||
TextColumn get episode => text().nullable()();
|
||||
IntColumn get progress => integer().withDefault(const Constant(0))();
|
||||
RealColumn get duration => real().withDefault(const Constant(0))();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get lastSyncedAt => dateTime().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:madari_client/features/settings/types/connection.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import 'engine.dart';
|
||||
|
||||
part 'connection.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<Connection>> getConnections(Ref ref) async {
|
||||
final List<Connection> returnValue = [];
|
||||
|
||||
final result = await AppEngine.engine.pb
|
||||
.collection("connection")
|
||||
.getFullList(expand: "type");
|
||||
|
||||
for (final item in result) {
|
||||
if (item.id == "telegram") {
|
||||
continue;
|
||||
}
|
||||
returnValue.add(
|
||||
Connection(
|
||||
id: item.id,
|
||||
title: item.getStringValue("title"),
|
||||
type: item.getStringValue("expand.type.type"),
|
||||
config: jsonEncode(item.get("config")),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return returnValue;
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:madari_client/engine/engine.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'connection_type.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<ResultList<ConnectionTypeRecord>> connectionTypeList(Ref ref,
|
||||
{int page = 1}) async {
|
||||
final result = await AppEngine.engine.pb
|
||||
.collection("connection_type")
|
||||
.getList(page: page, sort: "order");
|
||||
|
||||
return ResultList(
|
||||
items: result.items
|
||||
.map(
|
||||
(item) => ConnectionTypeRecord.fromRecord(item),
|
||||
)
|
||||
.toList(),
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
);
|
||||
}
|
||||
|
||||
class ConnectionTypeRecord extends Jsonable {
|
||||
final String title;
|
||||
final String icon;
|
||||
final String type;
|
||||
final String id;
|
||||
|
||||
ConnectionTypeRecord({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.type,
|
||||
required this.id,
|
||||
});
|
||||
|
||||
factory ConnectionTypeRecord.fromRecord(RecordModel record) =>
|
||||
ConnectionTypeRecord.fromJson(record.toJson());
|
||||
|
||||
factory ConnectionTypeRecord.fromJson(Map<String, dynamic> json) =>
|
||||
_$ConnectionTypeRecordFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ConnectionTypeRecordToJson(this);
|
||||
}
|
||||
|
||||
ConnectionTypeRecord _$ConnectionTypeRecordFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
ConnectionTypeRecord(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
icon: json['icon'] as String,
|
||||
type: json['type'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ConnectionTypeRecordToJson(
|
||||
ConnectionTypeRecord instance) =>
|
||||
<String, dynamic>{
|
||||
'title': instance.title,
|
||||
'icon': instance.icon,
|
||||
'type': instance.type,
|
||||
'id': instance.id,
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http_client;
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/database_provider.dart';
|
||||
|
||||
class AppEngine {
|
||||
static late AppEngine _instance;
|
||||
late final DatabaseProvider _databaseProvider;
|
||||
|
||||
static Future<void> ensureInitialized() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final store = AsyncAuthStore(
|
||||
save: (String data) async => prefs.setString('pb_auth', data),
|
||||
initial: prefs.getString('pb_auth'),
|
||||
clear: prefs.clear,
|
||||
);
|
||||
|
||||
_instance = AppEngine(store);
|
||||
}
|
||||
|
||||
static AppEngine get engine => _instance;
|
||||
|
||||
late final PocketBase pb;
|
||||
late final http_client.Client http;
|
||||
|
||||
AppDatabase get database => _databaseProvider.database;
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _databaseProvider.close();
|
||||
}
|
||||
|
||||
AppEngine(AuthStore authStore) {
|
||||
pb = PocketBase(
|
||||
(kDebugMode ? 'http://100.64.0.1:8090' : 'https://api.madari.media'),
|
||||
authStore: authStore,
|
||||
);
|
||||
_databaseProvider = DatabaseProvider();
|
||||
http = pb.httpClientFactory();
|
||||
}
|
||||
|
||||
Future<RecordAuth> signIn(String username, String password) {
|
||||
return pb.collection('users').authWithPassword(username, password);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:madari_client/engine/engine.dart';
|
||||
import 'package:madari_client/features/watch_history/service/base_watch_history.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../features/connections/types/base/base.dart';
|
||||
import '../features/watch_history/service/zeee_watch_history.dart';
|
||||
|
||||
export '../features/connections/types/base/base.dart';
|
||||
|
||||
part 'library.g.dart';
|
||||
|
||||
final Map<String, ResultList<LibraryRecord>> _libraryListCache = {};
|
||||
|
||||
@riverpod
|
||||
Future<ResultList<LibraryRecord>> libraryList(Ref ref, int page) async {
|
||||
if (_libraryListCache.containsKey(page.toString())) {
|
||||
return _libraryListCache[page.toString()]!;
|
||||
}
|
||||
|
||||
final result = await AppEngine.engine.pb.collection("library").getList(
|
||||
page: page,
|
||||
sort: "+order",
|
||||
);
|
||||
|
||||
final returnValue = ResultList<LibraryRecord>();
|
||||
|
||||
returnValue.totalItems = result.totalItems;
|
||||
returnValue.perPage = result.perPage;
|
||||
returnValue.page = result.page;
|
||||
returnValue.totalPages = result.totalPages;
|
||||
returnValue.items = result.items.where((item) {
|
||||
final connectionType = item.getStringValue("connectionType");
|
||||
|
||||
if (connectionType != "telegram") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}).map((item) {
|
||||
final i = item;
|
||||
return LibraryRecord.fromJson({
|
||||
"id": i.id,
|
||||
"connectionType": i.getStringValue("connectionType"),
|
||||
"icon": i.getStringValue("icon"),
|
||||
"title": i.getStringValue("title"),
|
||||
"types": i.getListValue<String>("types"),
|
||||
"config": i.getStringValue("config"),
|
||||
"connection": i.getStringValue("connection"),
|
||||
});
|
||||
}).toList();
|
||||
|
||||
_libraryListCache[page.toString()] = returnValue;
|
||||
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
final Map<String, ResultList<LibraryItemList>> _cache = {};
|
||||
|
||||
@riverpod
|
||||
Future<ResultList<LibraryItemList>> libraryItemList(
|
||||
Ref ref,
|
||||
LibraryRecord library,
|
||||
List<LibraryItemList>? item,
|
||||
int page,
|
||||
String? search,
|
||||
) async {
|
||||
final cache = "${library.id}_${page}_$search";
|
||||
|
||||
final history = ZeeeWatchHistoryStatic.service;
|
||||
|
||||
final result = _cache[cache]!.items.map((item) {
|
||||
return WatchHistoryGetRequest(
|
||||
id: item.id,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final watchHistory = await history!.getItemWatchHistory(ids: result);
|
||||
|
||||
_cache[cache]!.items = _cache[cache]!.items.map((item) {
|
||||
final history = watchHistory.where((history) => history.id == item.id);
|
||||
|
||||
item.history = history.isEmpty ? null : history.first;
|
||||
return item;
|
||||
}).toList();
|
||||
|
||||
return _cache[cache]!;
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class LibraryItemList extends Jsonable {
|
||||
final String title;
|
||||
final String? logo;
|
||||
final int? size;
|
||||
final String? extra;
|
||||
final dynamic id;
|
||||
final String? config;
|
||||
final DateTime? date;
|
||||
final double? popularity;
|
||||
WatchHistory? history;
|
||||
|
||||
LibraryItemList({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.config,
|
||||
this.logo,
|
||||
this.size,
|
||||
this.date,
|
||||
this.extra,
|
||||
this.popularity = 0,
|
||||
this.history,
|
||||
});
|
||||
|
||||
factory LibraryItemList.fromRecord(RecordModel record) =>
|
||||
LibraryItemList.fromJson(record.toJson());
|
||||
|
||||
factory LibraryItemList.fromJson(Map<String, dynamic> json) =>
|
||||
_$LibraryItemListFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$LibraryItemListToJson(this);
|
||||
}
|
||||
|
||||
class FolderItem {
|
||||
final String title;
|
||||
final String id;
|
||||
final Widget? icon;
|
||||
final String? config;
|
||||
|
||||
FolderItem({
|
||||
required this.title,
|
||||
required this.id,
|
||||
this.icon,
|
||||
this.config,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
extension UIImageToInputImage on ui.Image {
|
||||
Future<File> toFile({String? fileName}) async {
|
||||
// Convert image to byte data
|
||||
final byteData = await toByteData(format: ui.ImageByteFormat.png);
|
||||
|
||||
if (byteData == null) {
|
||||
throw Exception('Failed to convert ui.Image to ByteData');
|
||||
}
|
||||
|
||||
// Get the application temporary directory
|
||||
final directory = await getTemporaryDirectory();
|
||||
|
||||
// Create a file with a unique name if not provided
|
||||
final file = File(
|
||||
'${directory.path}/${fileName ?? 'image_${DateTime.now().millisecondsSinceEpoch}.png'}');
|
||||
|
||||
// Write bytes to file
|
||||
await file.writeAsBytes(byteData.buffer.asUint8List());
|
||||
|
||||
return file;
|
||||
}
|
||||
}
|
||||
184
lib/features/accounts/container/trakt.container.dart
Normal file
184
lib/features/accounts/container/trakt.container.dart
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../common/utils/refresh_auth.dart';
|
||||
import '../../pocketbase/service/pocketbase.service.dart';
|
||||
|
||||
class TraktContainer extends StatefulWidget {
|
||||
const TraktContainer({super.key});
|
||||
|
||||
@override
|
||||
State<TraktContainer> createState() => _TraktContainerState();
|
||||
}
|
||||
|
||||
class _TraktContainerState extends State<TraktContainer> {
|
||||
final pb = AppPocketBaseService.instance.pb;
|
||||
bool isLoggedIn = false;
|
||||
bool isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
checkIsLoggedIn();
|
||||
}
|
||||
|
||||
void checkIsLoggedIn() {
|
||||
final traktToken = pb.authStore.record!.getStringValue("trakt_token");
|
||||
setState(() {
|
||||
isLoggedIn = traktToken != "";
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeAccount() async {
|
||||
setState(() => isLoading = true);
|
||||
try {
|
||||
final record = pb.authStore.record!;
|
||||
record.set("trakt_token", "");
|
||||
|
||||
await pb.collection('users').update(
|
||||
record.id,
|
||||
body: record.toJson(),
|
||||
);
|
||||
|
||||
await refreshAuth();
|
||||
setState(() {
|
||||
isLoggedIn = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString()),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loginWithTrakt() async {
|
||||
setState(() => isLoading = true);
|
||||
try {
|
||||
await pb.collection("users").authWithOAuth2(
|
||||
"oidc",
|
||||
(url) async {
|
||||
final newUrl = Uri.parse(
|
||||
url.toString().replaceFirst(
|
||||
"scope=openid&",
|
||||
"",
|
||||
),
|
||||
);
|
||||
await launchUrl(newUrl);
|
||||
},
|
||||
scopes: ["openid"],
|
||||
);
|
||||
|
||||
await refreshAuth();
|
||||
checkIsLoggedIn();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString()),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
final isDesktopOrTV = screenWidth > 1024;
|
||||
final isTablet = screenWidth > 600 && screenWidth <= 1024;
|
||||
final horizontalPadding = isDesktopOrTV
|
||||
? screenWidth * 0.2
|
||||
: isTablet
|
||||
? 48.0
|
||||
: 24.0;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
vertical: 24,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
isLoggedIn ? 'Connected to Trakt' : 'Connect with Trakt',
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isLoggedIn
|
||||
? 'Your Trakt account is connected'
|
||||
: 'Sign in to track your movies and shows',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: (isLoggedIn ? removeAccount : loginWithTrakt),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor:
|
||||
isLoggedIn ? colorScheme.error : colorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
).copyWith(
|
||||
overlayColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.hovered)) {
|
||||
return colorScheme.onPrimary.withValues(alpha: 0.08);
|
||||
}
|
||||
if (states.contains(WidgetState.pressed)) {
|
||||
return colorScheme.onPrimary.withValues(alpha: 0.12);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
isLoggedIn
|
||||
? 'Disconnect Account'
|
||||
: 'Connect with Trakt',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/features/accounts/pages/external_account.dart
Normal file
54
lib/features/accounts/pages/external_account.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/accounts/container/trakt.container.dart';
|
||||
|
||||
import '../../settings/widget/setting_wrapper.dart';
|
||||
|
||||
class ExternalAccount extends StatelessWidget {
|
||||
const ExternalAccount({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("External Accounts"),
|
||||
),
|
||||
body: SettingWrapper(
|
||||
child: ListView(
|
||||
children: [
|
||||
_buildSection(
|
||||
"Trakt",
|
||||
[
|
||||
const TraktContainer(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(String title, List<Widget> children) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
205
lib/features/auth/pages/forget_password_page.dart
Normal file
205
lib/features/auth/pages/forget_password_page.dart
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../pocketbase/service/pocketbase.service.dart';
|
||||
|
||||
class ForgotPasswordPage extends StatefulWidget {
|
||||
const ForgotPasswordPage({super.key});
|
||||
|
||||
@override
|
||||
State<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _emailFocusNode = FocusNode();
|
||||
bool _isLoading = false;
|
||||
final pocketbase = AppPocketBaseService.instance.pb;
|
||||
|
||||
Widget _buildEmailField() {
|
||||
return TextFormField(
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocusNode,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: const Icon(Icons.email_outlined),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true) {
|
||||
return 'Please enter your email';
|
||||
}
|
||||
if (!RegExp(r'^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value!)) {
|
||||
return 'Please enter a valid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) => _requestPasswordReset(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestPasswordReset() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await pocketbase
|
||||
.collection('users')
|
||||
.requestPasswordReset(_emailController.text.trim());
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Password reset instructions have been sent to your email'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
// Navigate back to sign in page
|
||||
context.go('/signin');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to send reset instructions: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
onPressed: () => context.go('/signin'),
|
||||
),
|
||||
),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Hero(
|
||||
tag: 'app_logo',
|
||||
child: Image.asset(
|
||||
'assets/icon/icon_mini.png',
|
||||
height: 80,
|
||||
width: 80,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Forgot Password',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter your email address to receive password reset instructions',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildEmailField(),
|
||||
const SizedBox(height: 24),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _requestPasswordReset,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Reset Password',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_emailFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
517
lib/features/auth/pages/signin_page.dart
Normal file
517
lib/features/auth/pages/signin_page.dart
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../common/utils/error_handler.dart';
|
||||
import '../../pocketbase/service/pocketbase.service.dart';
|
||||
import '../../theme/theme/app_theme.dart';
|
||||
|
||||
class SignInPage extends StatefulWidget {
|
||||
const SignInPage({super.key});
|
||||
|
||||
@override
|
||||
State<SignInPage> createState() => _SignInPageState();
|
||||
}
|
||||
|
||||
class _SignInPageState extends State<SignInPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _emailFocusNode = FocusNode();
|
||||
final _passwordFocusNode = FocusNode();
|
||||
final _signInButtonFocusNode = FocusNode();
|
||||
final _forgotPasswordFocusNode = FocusNode();
|
||||
final _signUpButtonFocusNode = FocusNode();
|
||||
final _themeToggleFocusNode = FocusNode();
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
final pocketbase = AppPocketBaseService.instance.pb;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
_animationController.forward();
|
||||
|
||||
// Set initial focus to email field
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
FocusScope.of(context).requestFocus(_emailFocusNode);
|
||||
});
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildAnimatedTextField({
|
||||
required TextEditingController controller,
|
||||
required FocusNode focusNode,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required String? Function(String?) validator,
|
||||
bool isPassword = false,
|
||||
required TextInputType keyboardType,
|
||||
required TextInputAction textInputAction,
|
||||
required List<String> autofillHints,
|
||||
required VoidCallback? onEditingComplete,
|
||||
}) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: const Duration(milliseconds: 500),
|
||||
builder: (context, value, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - value)),
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey: (RawKeyEvent event) {
|
||||
if (event is RawKeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
if (focusNode == _emailFocusNode) {
|
||||
FocusScope.of(context).requestFocus(_passwordFocusNode);
|
||||
} else if (focusNode == _passwordFocusNode) {
|
||||
FocusScope.of(context).requestFocus(_forgotPasswordFocusNode);
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
if (focusNode == _passwordFocusNode) {
|
||||
FocusScope.of(context).requestFocus(_emailFocusNode);
|
||||
} else if (focusNode == _forgotPasswordFocusNode) {
|
||||
FocusScope.of(context).requestFocus(_passwordFocusNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
obscureText: isPassword ? _obscurePassword : false,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
autofillHints: autofillHints,
|
||||
onEditingComplete: onEditingComplete,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon),
|
||||
suffixIcon: isPassword
|
||||
? IconButton(
|
||||
focusNode: FocusNode(),
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
_obscurePassword ? 'Show password' : 'Hide password',
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
validator: validator,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeToggle() {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Focus(
|
||||
focusNode: _themeToggleFocusNode,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: _themeToggleFocusNode.hasFocus
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
focusNode: FocusNode(),
|
||||
icon: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return RotationTransition(
|
||||
turns: animation,
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
isDark ? Icons.light_mode : Icons.dark_mode,
|
||||
key: ValueKey<bool>(isDark),
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
AppTheme().toggleTheme();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey: (RawKeyEvent event) {
|
||||
if (event is RawKeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.select) {
|
||||
// Handle select button press based on current focus
|
||||
if (_signInButtonFocusNode.hasFocus) {
|
||||
_signIn();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: _buildThemeToggle(),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Hero(
|
||||
tag: 'app_logo',
|
||||
child: Image.asset(
|
||||
'assets/icon/icon_mini.png',
|
||||
height: 80,
|
||||
width: 80,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Madari',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Welcome back',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildAnimatedTextField(
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocusNode,
|
||||
label: 'Email',
|
||||
icon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
onEditingComplete: () {
|
||||
FocusScope.of(context)
|
||||
.requestFocus(_passwordFocusNode);
|
||||
},
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true) {
|
||||
return 'Please enter your email';
|
||||
}
|
||||
if (!RegExp(
|
||||
r'^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||
.hasMatch(value!)) {
|
||||
return 'Please enter a valid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildAnimatedTextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
label: 'Password',
|
||||
icon: Icons.lock_outline,
|
||||
isPassword: true,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofillHints: const [
|
||||
AutofillHints.password
|
||||
],
|
||||
onEditingComplete: () {
|
||||
FocusScope.of(context)
|
||||
.requestFocus(_signInButtonFocusNode);
|
||||
},
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true) {
|
||||
return 'Please enter your password';
|
||||
}
|
||||
if (value!.length < 6) {
|
||||
return 'Password must be at least 6 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Focus(
|
||||
focusNode: _forgotPasswordFocusNode,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
context.go('/forgot-password');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
),
|
||||
child: const Text('Forgot Password?'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Focus(
|
||||
focusNode: _signInButtonFocusNode,
|
||||
child: AnimatedContainer(
|
||||
duration:
|
||||
const Duration(milliseconds: 300),
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _signInButtonFocusNode.hasFocus
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _signIn,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor:
|
||||
colorScheme.onPrimary,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<
|
||||
Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Sign In',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Don't have an account? ",
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Focus(
|
||||
focusNode: _signUpButtonFocusNode,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
context.go('/signup');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _signUpButtonFocusNode
|
||||
.hasFocus
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: const Text(
|
||||
'Sign Up',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _signIn() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await pocketbase.collection('users').authWithPassword(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
context.go('/profile');
|
||||
}
|
||||
} on ClientException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(getErrorMessage(e)),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_emailFocusNode.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
_signInButtonFocusNode.dispose();
|
||||
_forgotPasswordFocusNode.dispose();
|
||||
_signUpButtonFocusNode.dispose();
|
||||
_themeToggleFocusNode.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
538
lib/features/auth/pages/signup_page.dart
Normal file
538
lib/features/auth/pages/signup_page.dart
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/consts/data.dart';
|
||||
import 'package:madari_client/features/settings/service/selected_profile.dart';
|
||||
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
|
||||
import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../common/utils/error_handler.dart';
|
||||
import '../../pocketbase/service/pocketbase.service.dart';
|
||||
import '../../theme/theme/app_theme.dart';
|
||||
import '../service/layout_service.dart';
|
||||
|
||||
class SignUpPage extends StatefulWidget {
|
||||
const SignUpPage({super.key});
|
||||
|
||||
@override
|
||||
State<SignUpPage> createState() => _SignUpPageState();
|
||||
}
|
||||
|
||||
class _SignUpPageState extends State<SignUpPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _logger = Logger("SignUpPage");
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
final _nameFocusNode = FocusNode();
|
||||
final _emailFocusNode = FocusNode();
|
||||
final _passwordFocusNode = FocusNode();
|
||||
final _confirmPasswordFocusNode = FocusNode();
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
final pocketbase = AppPocketBaseService.instance.pb;
|
||||
|
||||
void _setupKeyboardListeners() {
|
||||
_nameFocusNode.addListener(() {
|
||||
if (!_nameFocusNode.hasFocus && _nameController.text.isNotEmpty) {
|
||||
_formKey.currentState?.validate();
|
||||
}
|
||||
});
|
||||
|
||||
_emailFocusNode.addListener(() {
|
||||
if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) {
|
||||
_formKey.currentState?.validate();
|
||||
}
|
||||
});
|
||||
|
||||
_passwordFocusNode.addListener(() {
|
||||
if (!_passwordFocusNode.hasFocus && _passwordController.text.isNotEmpty) {
|
||||
_formKey.currentState?.validate();
|
||||
}
|
||||
});
|
||||
|
||||
_confirmPasswordFocusNode.addListener(() {
|
||||
if (!_confirmPasswordFocusNode.hasFocus &&
|
||||
_confirmPasswordController.text.isNotEmpty) {
|
||||
_formKey.currentState?.validate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
_setupKeyboardListeners();
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildAnimatedTextField({
|
||||
required TextEditingController controller,
|
||||
required FocusNode focusNode,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required String? Function(String?) validator,
|
||||
bool isPassword = false,
|
||||
required TextInputType keyboardType,
|
||||
required TextInputAction textInputAction,
|
||||
required List<String> autofillHints,
|
||||
required VoidCallback? onEditingComplete,
|
||||
bool isConfirmPassword = false,
|
||||
}) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: const Duration(milliseconds: 500),
|
||||
builder: (context, value, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - value)),
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
obscureText: isPassword
|
||||
? (isConfirmPassword ? _obscureConfirmPassword : _obscurePassword)
|
||||
: false,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
autofillHints: autofillHints,
|
||||
onEditingComplete: onEditingComplete,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon),
|
||||
suffixIcon: isPassword
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
(isConfirmPassword
|
||||
? _obscureConfirmPassword
|
||||
: _obscurePassword)
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (isConfirmPassword) {
|
||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||
} else {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip: (isConfirmPassword
|
||||
? _obscureConfirmPassword
|
||||
: _obscurePassword)
|
||||
? 'Show password'
|
||||
: 'Hide password',
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color:
|
||||
Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
validator: validator,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeToggle() {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return RotationTransition(
|
||||
turns: animation,
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
isDark ? Icons.light_mode : Icons.dark_mode,
|
||||
key: ValueKey<bool>(isDark),
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
AppTheme().toggleTheme();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: _buildThemeToggle(),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Hero(
|
||||
tag: 'app_logo',
|
||||
child: Image.asset(
|
||||
'assets/icon/icon_mini.png',
|
||||
height: 80,
|
||||
width: 80,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Madari',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Create your account',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildAnimatedTextField(
|
||||
controller: _nameController,
|
||||
focusNode: _nameFocusNode,
|
||||
label: 'Name',
|
||||
icon: Icons.person_outline,
|
||||
keyboardType: TextInputType.name,
|
||||
textInputAction: TextInputAction.next,
|
||||
autofillHints: const [AutofillHints.name],
|
||||
onEditingComplete: () {
|
||||
_emailFocusNode.requestFocus();
|
||||
},
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true) {
|
||||
return 'Please enter your name';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildAnimatedTextField(
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocusNode,
|
||||
label: 'Email',
|
||||
icon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
onEditingComplete: () {
|
||||
_passwordFocusNode.requestFocus();
|
||||
},
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true) {
|
||||
return 'Please enter your email';
|
||||
}
|
||||
if (!RegExp(
|
||||
r'^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||
.hasMatch(value!)) {
|
||||
return 'Please enter a valid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildAnimatedTextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
label: 'Password',
|
||||
icon: Icons.lock_outline,
|
||||
isPassword: true,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.next,
|
||||
autofillHints: const [
|
||||
AutofillHints.newPassword
|
||||
],
|
||||
onEditingComplete: () {
|
||||
_confirmPasswordFocusNode.requestFocus();
|
||||
},
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true) {
|
||||
return 'Please enter your password';
|
||||
}
|
||||
if (value!.length < 6) {
|
||||
return 'Password must be at least 6 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildAnimatedTextField(
|
||||
controller: _confirmPasswordController,
|
||||
focusNode: _confirmPasswordFocusNode,
|
||||
label: 'Confirm Password',
|
||||
icon: Icons.lock_outline,
|
||||
isPassword: true,
|
||||
isConfirmPassword: true,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofillHints: const [
|
||||
AutofillHints.newPassword
|
||||
],
|
||||
onEditingComplete: _signUp,
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true) {
|
||||
return 'Please confirm your password';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _signUp,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Sign Up',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Already have an account? ',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/signin');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
),
|
||||
child: const Text(
|
||||
'Sign In',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _signUp() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final userData = {
|
||||
"email": _emailController.text.trim(),
|
||||
"password": _passwordController.text,
|
||||
"passwordConfirm": _confirmPasswordController.text,
|
||||
"name": _nameController.text.trim(),
|
||||
};
|
||||
|
||||
final user = await pocketbase.collection('users').create(body: userData);
|
||||
|
||||
await pocketbase.collection('users').authWithPassword(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
|
||||
final profile = await pocketbase.collection('account_profile').create(
|
||||
body: {
|
||||
"name": _nameController.text.trim(),
|
||||
"can_search": true,
|
||||
'user': pocketbase.authStore.record!.id,
|
||||
},
|
||||
);
|
||||
|
||||
await SelectedProfileService.instance.setSelectedProfile(profile.id);
|
||||
|
||||
for (final defaultAddon in defaultAppAddons) {
|
||||
final manifest = await StremioAddonService.instance
|
||||
.validateManifest(defaultAddon.url, noCache: true)
|
||||
.queryFn();
|
||||
await StremioAddonService.instance.saveAddon(manifest);
|
||||
}
|
||||
|
||||
await LayoutService.instance.addAllHomeWidgets();
|
||||
|
||||
if (mounted) {
|
||||
context.go('/profile');
|
||||
}
|
||||
} on ClientException catch (e, stack) {
|
||||
_logger.warning("Unable to sign up", e, stack);
|
||||
if (mounted) {
|
||||
String errorMessage = getErrorMessage(e);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
action: SnackBarAction(
|
||||
label: 'OK',
|
||||
textColor: Theme.of(context).colorScheme.onError,
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_nameFocusNode.dispose();
|
||||
_emailFocusNode.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
_confirmPasswordFocusNode.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
56
lib/features/auth/service/layout_service.dart
Normal file
56
lib/features/auth/service/layout_service.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../pocketbase/service/pocketbase.service.dart';
|
||||
import '../../widgetter/plugin_base.dart';
|
||||
import '../../widgetter/service/home_layout_service.dart';
|
||||
|
||||
class LayoutService {
|
||||
static final LayoutService _instance = LayoutService._internal();
|
||||
static LayoutService get instance => _instance;
|
||||
|
||||
final _logger = Logger('LayoutService');
|
||||
|
||||
LayoutService._internal();
|
||||
|
||||
Future<(int, String?)> addAllHomeWidgets() async {
|
||||
try {
|
||||
_logger.info('Adding all widgets for new account');
|
||||
|
||||
final userId = AppPocketBaseService.instance.pb.authStore.record!.id;
|
||||
int successCount = 0;
|
||||
|
||||
final result = PluginRegistry.instance.getAvailablePlugins();
|
||||
final presets = await Future.wait(
|
||||
result.map((item) => item.presets()),
|
||||
);
|
||||
|
||||
final allWidgets = presets.expand((element) => element).toList();
|
||||
|
||||
for (final preset in allWidgets) {
|
||||
final newWidget = LayoutWidgetConfig.fromPreset(
|
||||
preset,
|
||||
userId,
|
||||
successCount,
|
||||
);
|
||||
|
||||
final success = await HomeLayoutService.instance.saveLayoutWidget(
|
||||
newWidget,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
_logger.info('Successfully added $successCount widgets');
|
||||
return (successCount, null);
|
||||
} else {
|
||||
return (0, 'Failed to add widgets. Please try again.');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe('Error adding all widgets', e);
|
||||
return (0, 'Failed to add widgets. Please check your connection.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/engine/engine.dart';
|
||||
import 'package:madari_client/utils/ocr_file.dart';
|
||||
|
||||
class ChatAction extends StatefulWidget {
|
||||
final void Function({
|
||||
String? actionId,
|
||||
Map<String, String>? files,
|
||||
}) onClose;
|
||||
final String? actionId;
|
||||
|
||||
const ChatAction({
|
||||
super.key,
|
||||
required this.onClose,
|
||||
this.actionId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatAction> createState() => _ChatActionState();
|
||||
}
|
||||
|
||||
class _ChatActionState extends State<ChatAction> {
|
||||
final List<Map<String, String>> _commandItems = [
|
||||
{
|
||||
'id': 'file-upload',
|
||||
'title': 'Upload File',
|
||||
'description': 'Share a document or image',
|
||||
},
|
||||
];
|
||||
String? content;
|
||||
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
AppEngine.engine.pb
|
||||
.collection("ai_action")
|
||||
.getList(perPage: 50)
|
||||
.then((docs) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final item in docs.items) {
|
||||
_commandItems.add({
|
||||
'id': item.id,
|
||||
'title': item.getStringValue("title"),
|
||||
'description': item.getStringValue("description"),
|
||||
});
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}).catchError((err) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void attachItem() async {
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: [
|
||||
"pdf",
|
||||
"png",
|
||||
"bpm",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
],
|
||||
);
|
||||
|
||||
if ((result?.count ?? 0) == 0) {
|
||||
widget.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
final images = await ocrFiles(result!.files);
|
||||
Map<String, String> files = {};
|
||||
|
||||
for (final (index, image) in images.indexed) {
|
||||
files[result.files[index].name] = image;
|
||||
}
|
||||
|
||||
widget.onClose(
|
||||
files: files,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Material(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _commandItems.length + (_isLoading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _commandItems.length) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 24,
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final item = _commandItems[index];
|
||||
return ListTile(
|
||||
selected: item['id'] == widget.actionId,
|
||||
leading: Icon(
|
||||
item['id'] == 'file-upload'
|
||||
? Icons.file_present
|
||||
: Icons.chat_bubble_outline,
|
||||
),
|
||||
title: Text(item['title'] ?? ''),
|
||||
subtitle: Text(
|
||||
item['description'] ?? '',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
if (item['id'] == 'file-upload') {
|
||||
return attachItem();
|
||||
}
|
||||
|
||||
widget.onClose(
|
||||
actionId: item['id'],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
|
||||
class ChatBubble extends StatelessWidget {
|
||||
final String message;
|
||||
final bool isUser;
|
||||
final bool isComplete;
|
||||
final VoidCallback? onCancel;
|
||||
final bool isStreaming = false;
|
||||
final int length;
|
||||
|
||||
const ChatBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isUser,
|
||||
this.isComplete = true,
|
||||
this.onCancel,
|
||||
this.length = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||
child: AlertDialog(
|
||||
contentPadding: const EdgeInsets.only(
|
||||
bottom: 12,
|
||||
),
|
||||
insetPadding: const EdgeInsets.all(10),
|
||||
title: const Text("Message"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: message),
|
||||
);
|
||||
},
|
||||
label: const Text('Copy'),
|
||||
icon: const Icon(Icons.copy),
|
||||
),
|
||||
],
|
||||
content: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight:
|
||||
MediaQuery.of(context).size.height - 220,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
MarkdownBody(
|
||||
data: message,
|
||||
selectable: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? Theme.of(context).primaryColor.withOpacity(0.9)
|
||||
: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: !isUser
|
||||
? Border.all(color: Colors.grey.withOpacity(0.2))
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
message.trim().isEmpty && isUser
|
||||
? Column(
|
||||
children: [
|
||||
const Icon(Icons.file_present),
|
||||
Text("Files Attached $length"),
|
||||
],
|
||||
)
|
||||
: MarkdownBody(
|
||||
data: message,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: TextStyle(
|
||||
color: isUser
|
||||
? Colors.white
|
||||
: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.color,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUser) _buildAvatar(true),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(bool isUser) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: isUser ? Colors.blue.shade700 : Colors.grey.shade900,
|
||||
child: Icon(
|
||||
isUser ? Icons.person : Icons.auto_awesome,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/chat/container/chat_empty_state.dart';
|
||||
|
||||
import 'chat_bubble.dart';
|
||||
import 'chat_input_area.dart';
|
||||
|
||||
class ChatMessage {
|
||||
final String message;
|
||||
final bool isUser;
|
||||
final bool isComplete;
|
||||
final List<String> files;
|
||||
final CancellationToken? cancellationToken;
|
||||
final String? actionId;
|
||||
|
||||
ChatMessage({
|
||||
required this.message,
|
||||
required this.isUser,
|
||||
this.files = const [],
|
||||
this.isComplete = true,
|
||||
this.cancellationToken,
|
||||
this.actionId,
|
||||
});
|
||||
}
|
||||
|
||||
class CancellationToken {
|
||||
bool _isCancelled = false;
|
||||
bool get isCancelled => _isCancelled;
|
||||
void cancel() => _isCancelled = true;
|
||||
}
|
||||
|
||||
class ChatContainer extends StatefulWidget {
|
||||
final List<ChatMessage>? initialMessages;
|
||||
final Future<void> Function(String, List<String>?, String?)? onSendMessage;
|
||||
final ScrollController scrollController;
|
||||
|
||||
const ChatContainer({
|
||||
super.key,
|
||||
this.initialMessages,
|
||||
this.onSendMessage,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatContainer> createState() => _ChatContainerState();
|
||||
}
|
||||
|
||||
class _ChatContainerState extends State<ChatContainer> {
|
||||
List<ChatMessage> messages = [];
|
||||
bool isLoading = false;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialMessages != null) {
|
||||
messages = widget.initialMessages!;
|
||||
}
|
||||
_focusNode.addListener(() {
|
||||
setState(() {
|
||||
_isExpanded = _focusNode.hasFocus;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit(
|
||||
String text,
|
||||
String? actionId,
|
||||
List<String> files,
|
||||
) async {
|
||||
if (text.trim().isEmpty && actionId == null) return;
|
||||
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
if (widget.onSendMessage != null) {
|
||||
await widget.onSendMessage!(text, files, actionId);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
files = [];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return ChatEmpty(
|
||||
handleSubmit: (text) => _handleSubmit(
|
||||
text,
|
||||
null,
|
||||
[],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: messages.isEmpty
|
||||
? _buildEmptyState()
|
||||
: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 1100,
|
||||
),
|
||||
child: ListView.builder(
|
||||
controller: widget.scrollController,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return ChatBubble(
|
||||
message: message.message,
|
||||
isUser: message.isUser,
|
||||
isComplete: message.isComplete,
|
||||
length: message.files.length,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 1100,
|
||||
),
|
||||
child: ChatInputArea(
|
||||
isLoading: isLoading,
|
||||
onSubmitted: (text, files, actionId) => _handleSubmit(
|
||||
text,
|
||||
actionId,
|
||||
files,
|
||||
),
|
||||
focusNode: _focusNode,
|
||||
cancellationToken: messages.lastOrNull?.cancellationToken,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatEmpty extends StatefulWidget {
|
||||
final Function(String input) handleSubmit;
|
||||
|
||||
const ChatEmpty({
|
||||
super.key,
|
||||
required this.handleSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatEmpty> createState() => _ChatEmptyState();
|
||||
}
|
||||
|
||||
class _ChatEmptyState extends State<ChatEmpty> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final suggestions = [
|
||||
"📚 How to create a study schedule?",
|
||||
"🧠 Best memory techniques for exams",
|
||||
"⏰ Time management tips for exam prep",
|
||||
"📝 Practice test strategies",
|
||||
"📱 Best study apps and tools",
|
||||
"🎯 How to stay focused while studying",
|
||||
"💡 Active recall techniques",
|
||||
"📊 Spaced repetition methods",
|
||||
"🌟 Exam day preparation tips",
|
||||
"✍️ Note-taking strategies",
|
||||
"🧘♂️ Study break activities",
|
||||
"👥 Group study benefits",
|
||||
];
|
||||
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final primaryColor =
|
||||
isDarkMode ? Colors.blue[400]! : Theme.of(context).primaryColor;
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Animated gradient container
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
primaryColor.withOpacity(0.3),
|
||||
primaryColor.withOpacity(0.1),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: primaryColor.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: primaryColor.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 48,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'How can I help you today?',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDarkMode ? Colors.white : primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Choose a suggestion or type your own question',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: suggestions
|
||||
.sublist(0, suggestions.length ~/ 2)
|
||||
.map((suggestion) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _buildSuggestionChip(
|
||||
suggestion,
|
||||
isDarkMode: isDarkMode,
|
||||
primaryColor: primaryColor,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: suggestions
|
||||
.sublist(suggestions.length ~/ 2)
|
||||
.map((suggestion) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _buildSuggestionChip(
|
||||
suggestion,
|
||||
isDarkMode: isDarkMode,
|
||||
primaryColor: primaryColor,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestionChip(
|
||||
String suggestion, {
|
||||
required bool isDarkMode,
|
||||
required Color primaryColor,
|
||||
}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => widget.handleSubmit(suggestion),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
isDarkMode ? Colors.grey[850]! : Theme.of(context).cardColor,
|
||||
isDarkMode
|
||||
? Colors.grey[900]!
|
||||
: Theme.of(context).cardColor.withOpacity(0.9),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
border: Border.all(
|
||||
color: isDarkMode
|
||||
? Colors.grey[800]!
|
||||
: primaryColor.withOpacity(0.1),
|
||||
),
|
||||
boxShadow: [
|
||||
if (!isDarkMode)
|
||||
BoxShadow(
|
||||
color: primaryColor.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 16,
|
||||
color: primaryColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: TextStyle(
|
||||
color: isDarkMode ? Colors.grey[300] : primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
import 'package:cached_query_flutter/cached_query_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
|
||||
class ChatHistory extends StatefulWidget {
|
||||
const ChatHistory({super.key});
|
||||
|
||||
@override
|
||||
State<ChatHistory> createState() => _ChatHistoryState();
|
||||
}
|
||||
|
||||
class _ChatHistoryState extends State<ChatHistory> {
|
||||
final pb = AppEngine.engine.pb;
|
||||
late final InfiniteQuery<List<RecordModel>, int> chatHistoryQuery;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
chatHistoryQuery = InfiniteQuery<List<RecordModel>, int>(
|
||||
key: "chat_history",
|
||||
queryFn: (page) async {
|
||||
try {
|
||||
final result = await pb.collection("chat").getList(
|
||||
page: page,
|
||||
perPage: 10, // Adjust perPage as needed
|
||||
sort: '-created', // Assuming you want the latest chats first
|
||||
);
|
||||
return result.items.toList();
|
||||
} catch (e) {
|
||||
debugPrint('Error fetching chat history: $e');
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
getNextArg: (state) {
|
||||
if (state.lastPage?.isEmpty ?? false) return null;
|
||||
return state.length;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Drawer(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
"Chat History",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
pb.collection("chat").create(
|
||||
body: {},
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.create_new_folder_outlined,
|
||||
),
|
||||
label: const Text("New Chat"),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: InfiniteQueryBuilder(
|
||||
query: chatHistoryQuery,
|
||||
builder: (ctx, state, query) {
|
||||
if (state.status == QueryStatus.loading &&
|
||||
state.data == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == QueryStatus.error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Failed to load chat history.',
|
||||
style: TextStyle(color: Colors.red.shade700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: query.refetch,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final items = state.data?.expand((e) => e).toList() ?? [];
|
||||
|
||||
if (items.isEmpty) {
|
||||
return const Center(child: Text("No chat history yet."));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: query.refetch,
|
||||
child: ListView.separated(
|
||||
controller: _scrollController,
|
||||
itemCount: items.length + (!state.hasReachedMax ? 1 : 0),
|
||||
separatorBuilder: (_, __) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
if (index < items.length) {
|
||||
final chat = items[index];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
chat.getStringValue("title") ?? "Untitled Chat",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
'Created: ${chat.created}', // Display creation date
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
} else if (!query.hasReachedMax()) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/engine/engine.dart';
|
||||
import 'package:madari_client/features/chat/container/chat_container.dart';
|
||||
|
||||
import 'chat_action.dart';
|
||||
|
||||
class ChatInputArea extends StatefulWidget {
|
||||
final bool isLoading;
|
||||
final Future Function(
|
||||
String text,
|
||||
List<String> files,
|
||||
String? actionId,
|
||||
) onSubmitted;
|
||||
final FocusNode focusNode;
|
||||
final CancellationToken? cancellationToken;
|
||||
|
||||
const ChatInputArea({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.onSubmitted,
|
||||
required this.focusNode,
|
||||
this.cancellationToken,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatInputArea> createState() => _ChatInputAreaState();
|
||||
}
|
||||
|
||||
class _ChatInputAreaState extends State<ChatInputArea> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
OverlayEntry? _overlayEntry;
|
||||
final Map<String, String> files =
|
||||
{}; // key is file name and string is the content
|
||||
String? actionId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController.addListener(_handleTextChange);
|
||||
|
||||
AppEngine.engine.pb
|
||||
.collection("ai_action")
|
||||
.getList(perPage: 50)
|
||||
.then((docs) {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideCommandPalette();
|
||||
_textController.removeListener(_handleTextChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTextChange() {
|
||||
if (_textController.text == '/') {
|
||||
_showCommandPalette();
|
||||
} else if (_textController.text.isEmpty) {
|
||||
_hideCommandPalette();
|
||||
}
|
||||
}
|
||||
|
||||
void _showCommandPalette() {
|
||||
_hideCommandPalette();
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: const Offset(0, -5),
|
||||
targetAnchor: Alignment.topCenter,
|
||||
followerAnchor: Alignment.bottomCenter,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
builder: (context, value, child) => Transform.scale(
|
||||
scale: value,
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: ChatAction(
|
||||
actionId: actionId,
|
||||
onClose: ({
|
||||
actionId,
|
||||
files,
|
||||
}) {
|
||||
_textController.clear();
|
||||
_hideCommandPalette();
|
||||
|
||||
setState(() {
|
||||
this.files.addAll(files ?? {});
|
||||
if (actionId != null) this.actionId = actionId;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _hideCommandPalette() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
if (mounted && context.mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (files.isNotEmpty)
|
||||
Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: files.length,
|
||||
itemBuilder: (context, index) {
|
||||
final fileName = files.keys.elementAt(index);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.all(4),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
label: const Text("Close"),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
label: const Text("Copy"),
|
||||
icon: const Icon(Icons.copy),
|
||||
),
|
||||
],
|
||||
title: Text(
|
||||
fileName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
files[fileName]!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.picture_as_pdf_outlined,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 100,
|
||||
),
|
||||
child: Text(
|
||||
fileName,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onDeleted: () {
|
||||
setState(() {
|
||||
files.remove(fileName);
|
||||
});
|
||||
},
|
||||
deleteIcon: const Icon(Icons.close, size: 16),
|
||||
backgroundColor:
|
||||
Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: _overlayEntry == null
|
||||
? const Icon(Icons.add_circle_outline)
|
||||
: const Icon(Icons.close),
|
||||
onPressed: _overlayEntry == null
|
||||
? _showCommandPalette
|
||||
: _hideCommandPalette,
|
||||
tooltip: 'Open commands',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Expanded(
|
||||
child: CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: widget.focusNode,
|
||||
maxLines: null,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Message...',
|
||||
border: InputBorder.none,
|
||||
hintStyle: TextStyle(color: Colors.grey),
|
||||
),
|
||||
onSubmitted: (value) async {
|
||||
await widget.onSubmitted(
|
||||
value,
|
||||
files.values.toList(),
|
||||
actionId,
|
||||
);
|
||||
setState(() {
|
||||
files.clear();
|
||||
actionId = null;
|
||||
});
|
||||
_textController.clear();
|
||||
},
|
||||
enabled: !widget.isLoading,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 2),
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
scale: _textController.text.isEmpty ? 0.8 : 1.0,
|
||||
child: Container(
|
||||
height: 40,
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.isLoading
|
||||
? Colors.grey
|
||||
: Theme.of(context).primaryColor.withOpacity(0.9),
|
||||
),
|
||||
child: widget.cancellationToken == null ||
|
||||
widget.cancellationToken?.isCancelled == true
|
||||
? IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(Icons.send,
|
||||
color: Colors.white, size: 20),
|
||||
onPressed: widget.isLoading
|
||||
? null
|
||||
: () async {
|
||||
await widget.onSubmitted(
|
||||
_textController.text,
|
||||
files.values.toList(),
|
||||
actionId,
|
||||
);
|
||||
setState(() {
|
||||
files.clear();
|
||||
actionId = null;
|
||||
});
|
||||
_textController.clear();
|
||||
},
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
widget.cancellationToken?.cancel();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.stop_circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/utils/ocr_file.dart';
|
||||
|
||||
import '../service/service.dart';
|
||||
|
||||
class AddCollectionItemSheet extends StatefulWidget {
|
||||
final String listId;
|
||||
|
||||
const AddCollectionItemSheet({
|
||||
super.key,
|
||||
required this.listId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddCollectionItemSheet> createState() => _AddCollectionItemSheetState();
|
||||
}
|
||||
|
||||
class _AddCollectionItemSheetState extends State<AddCollectionItemSheet>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _titleController = TextEditingController();
|
||||
late TabController _tabController;
|
||||
PlatformFile? _selectedFile;
|
||||
String? _fileType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
try {
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
_selectedFile = result.files.single;
|
||||
_fileType = result.files.single.extension;
|
||||
_titleController.text = result.files.single.name;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error picking file: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _saveItem() async {
|
||||
if (_titleController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please enter a title')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_selectedFile == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select a file')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await CollectionService.addItem(
|
||||
listId: widget.listId,
|
||||
name: _titleController.text,
|
||||
type: "file",
|
||||
file: _selectedFile!,
|
||||
content: (await ocrFiles([_selectedFile!])).first,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Add New Item'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
label: _isLoading ? const Text("Uploading...") : const Text("Upload"),
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
onPressed: _isLoading ? null : _saveItem,
|
||||
),
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildFileUploadTab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFileUploadTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_selectedFile != null)
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_selectedFile == null
|
||||
? Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.upload_file,
|
||||
size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _pickFile,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Select File'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Supported formats: PDF, JPG, PNG',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_fileType == 'pdf'
|
||||
? Icons.picture_as_pdf
|
||||
: Icons.image,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_selectedFile!.path?.split('/').last ?? "",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => setState(() {
|
||||
_selectedFile = null;
|
||||
_fileType = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'File type: ${_fileType?.toUpperCase()}',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../types/collection_item_model.dart';
|
||||
import 'collection_markdown_renderer.dart';
|
||||
|
||||
class CollectionItemRenderer extends StatelessWidget {
|
||||
final CollectionItemModel item;
|
||||
|
||||
const CollectionItemRenderer({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (item.type) {
|
||||
case 'markdown':
|
||||
return MarkdownRenderer(content: item.content?['text'] ?? '');
|
||||
// case 'file':
|
||||
// return FileRenderer(filePath: item.file!);
|
||||
default:
|
||||
return Text('Unsupported type: ${item.type}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
import '../service/service.dart';
|
||||
import '../types/collection_item_model.dart';
|
||||
import 'add_collection_item.dart';
|
||||
import 'collection_markdown_renderer.dart';
|
||||
import 'collection_search_delegate.dart';
|
||||
|
||||
class CollectionListItemsScreen extends StatefulWidget {
|
||||
final String listId;
|
||||
final bool isPublic;
|
||||
final String title;
|
||||
|
||||
const CollectionListItemsScreen({
|
||||
super.key,
|
||||
required this.listId,
|
||||
required this.isPublic,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CollectionListItemsScreen> createState() =>
|
||||
_CollectionListItemsScreenState();
|
||||
}
|
||||
|
||||
class _CollectionListItemsScreenState extends State<CollectionListItemsScreen> {
|
||||
String _sortBy = 'created';
|
||||
bool _ascending = false;
|
||||
late Future<List<CollectionItemModel>> _itemsFuture;
|
||||
|
||||
Future<void> _showAddItemSheet() async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => AddCollectionItemSheet(listId: widget.listId),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
setState(() => _refreshItems());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshItems();
|
||||
}
|
||||
|
||||
void _refreshItems() {
|
||||
_itemsFuture = CollectionService.getCollectionItems(
|
||||
listId: widget.listId,
|
||||
searchQuery: '',
|
||||
sortBy: _sortBy,
|
||||
ascending: _ascending,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<CollectionItemModel>>(
|
||||
future: _itemsFuture,
|
||||
builder: (context, snapshot) {
|
||||
return Scaffold(
|
||||
floatingActionButton: snapshot.hasData &&
|
||||
!widget.isPublic &&
|
||||
!kIsWeb &&
|
||||
(Platform.isIOS || Platform.isAndroid)
|
||||
? FloatingActionButton(
|
||||
onPressed: _showAddItemSheet,
|
||||
child: const Icon(Icons.add),
|
||||
)
|
||||
: null,
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title),
|
||||
actions: [
|
||||
// Search action
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
showSearch(
|
||||
context: context,
|
||||
delegate: CollectionSearchDelegate(widget.listId),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Sort menu
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.sort),
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
if (value == _sortBy) {
|
||||
_ascending = !_ascending;
|
||||
} else {
|
||||
_sortBy = value;
|
||||
}
|
||||
_refreshItems();
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
CheckedPopupMenuItem(
|
||||
value: 'created',
|
||||
checked: _sortBy == 'created',
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Created'),
|
||||
if (_sortBy == 'created')
|
||||
Icon(_ascending
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward),
|
||||
],
|
||||
),
|
||||
),
|
||||
CheckedPopupMenuItem(
|
||||
value: 'updated',
|
||||
checked: _sortBy == 'updated',
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Updated'),
|
||||
if (_sortBy == 'updated')
|
||||
Icon(_ascending
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Refresh button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => setState(() => _refreshItems()),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: Future.delayed(Duration.zero),
|
||||
builder: (ctx, _) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text('Error: ${snapshot.error}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final items = snapshot.data!;
|
||||
|
||||
if (items.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text('No items found'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ListTile(
|
||||
onTap: () async {
|
||||
if (item.file != null && item.file != "") {
|
||||
final fileToken =
|
||||
await AppEngine.engine.pb.files.getToken();
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return DocViewer(
|
||||
source: URLSource(
|
||||
title: item.name,
|
||||
url: "${item.file}?token=$fileToken",
|
||||
id: item.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final file = await AppEngine.engine.pb
|
||||
.collection("collection_item")
|
||||
.getOne(item.id);
|
||||
|
||||
if (context.mounted && mounted) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => FullMarkdownSheet(
|
||||
content: file.getStringValue("content") ?? "",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
leading: item.type == "markdown"
|
||||
? const Icon(Icons.document_scanner_outlined)
|
||||
: const Icon(Icons.file_present),
|
||||
title: Text(item.name),
|
||||
subtitle: Text(formatDate(item.updated)),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String formatDate(DateTime created) {
|
||||
return DateFormat("dd MMM yyyy").format(created);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
|
||||
class MarkdownRenderer extends StatelessWidget {
|
||||
final String content;
|
||||
final int previewLines;
|
||||
|
||||
const MarkdownRenderer({
|
||||
super.key,
|
||||
required this.content,
|
||||
this.previewLines = 3,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String previewText =
|
||||
content.split('\n').take(previewLines).join('\n');
|
||||
|
||||
final bool hasMore = content.split('\n').length > previewLines;
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _showFullMarkdown(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MarkdownBody(
|
||||
data: previewText,
|
||||
shrinkWrap: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: Theme.of(context).textTheme.bodyMedium,
|
||||
h1: Theme.of(context).textTheme.headlineSmall,
|
||||
h2: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
if (hasMore) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap to read more...',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFullMarkdown(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => FullMarkdownSheet(content: content),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FullMarkdownSheet extends StatelessWidget {
|
||||
final String content;
|
||||
|
||||
const FullMarkdownSheet({
|
||||
super.key,
|
||||
required this.content,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// Actions bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () {
|
||||
// Copy to clipboard
|
||||
// You might want to add feedback when copied
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: () {
|
||||
// Implement share functionality
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Markdown content
|
||||
Expanded(
|
||||
child: Markdown(
|
||||
controller: scrollController,
|
||||
data: content,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: Theme.of(context).textTheme.bodyLarge,
|
||||
h1: Theme.of(context).textTheme.headlineMedium,
|
||||
h2: Theme.of(context).textTheme.headlineSmall,
|
||||
h3: Theme.of(context).textTheme.titleLarge,
|
||||
code: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: Colors.grey[200],
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
selectable: true,
|
||||
padding: const EdgeInsets.all(16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
import '../../doc_viewer/container/doc_viewer.dart';
|
||||
import '../../doc_viewer/types/doc_source.dart';
|
||||
import '../service/service.dart';
|
||||
import '../types/collection_item_model.dart';
|
||||
import 'collection_markdown_renderer.dart';
|
||||
|
||||
class CollectionSearchDelegate extends SearchDelegate<String> {
|
||||
final String listId;
|
||||
|
||||
CollectionSearchDelegate(this.listId);
|
||||
|
||||
@override
|
||||
List<Widget> buildActions(BuildContext context) {
|
||||
return [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
query = '';
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildLeading(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
close(context, '');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildResults(BuildContext context) {
|
||||
return _buildSearchResults();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildSuggestions(BuildContext context) {
|
||||
return _buildSearchResults();
|
||||
}
|
||||
|
||||
Widget _buildSearchResults() {
|
||||
if (query.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text('Start typing to search'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<CollectionItemModel>>(
|
||||
future: CollectionService.getCollectionItems(
|
||||
listId: listId,
|
||||
searchQuery: query,
|
||||
sortBy: 'created',
|
||||
ascending: false,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final items = snapshot.data!;
|
||||
|
||||
if (items.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text('No results found'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ListTile(
|
||||
onTap: () async {
|
||||
if (item.file != null && item.file != "") {
|
||||
final fileToken = await AppEngine.engine.pb.files.getToken();
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return DocViewer(
|
||||
source: URLSource(
|
||||
title: item.name,
|
||||
url: "${item.file}?token=$fileToken",
|
||||
id: item.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final file = await AppEngine.engine.pb
|
||||
.collection("collection_item")
|
||||
.getOne(item.id);
|
||||
|
||||
if (context.mounted) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => FullMarkdownSheet(
|
||||
content: file.getStringValue("content") ?? "",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
leading: item.type == "markdown"
|
||||
? const Icon(Icons.document_scanner_outlined)
|
||||
: const Icon(Icons.file_present),
|
||||
title: Text(item.name),
|
||||
subtitle: Text(formatDate(item.updated)),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String formatDate(DateTime created) {
|
||||
return DateFormat("dd MMM yyyy").format(created);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
|
||||
class CreateCollectionBottomSheet extends StatefulWidget {
|
||||
final Function() onCollectionCreated;
|
||||
|
||||
const CreateCollectionBottomSheet({
|
||||
super.key,
|
||||
required this.onCollectionCreated,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CreateCollectionBottomSheet> createState() =>
|
||||
_CreateCollectionBottomSheetState();
|
||||
}
|
||||
|
||||
class _CreateCollectionBottomSheetState
|
||||
extends State<CreateCollectionBottomSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
bool _isPublic = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _createCollection() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final pb = AppEngine.engine.pb;
|
||||
|
||||
await pb.collection('collection').create(body: {
|
||||
'name': _nameController.text.trim(),
|
||||
'description': _descriptionController.text.trim(),
|
||||
'is_public': _isPublic,
|
||||
'order': 0,
|
||||
'user': pb.authStore.record!.id,
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
widget.onCollectionCreated();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to create collection: ${e.toString()}')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Create New Collection',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Collection Name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter a name';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (Optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Make Public'),
|
||||
subtitle: const Text('Allow others to view this collection'),
|
||||
value: _isPublic,
|
||||
onChanged: (value) => setState(() => _isPublic = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _createCollection,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Create Collection'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
import '../types/collection_item_model.dart';
|
||||
|
||||
class CollectionService {
|
||||
static Future<List<CollectionItemModel>> getCollectionItems({
|
||||
required String listId,
|
||||
String searchQuery = '',
|
||||
String sortBy = 'created',
|
||||
bool ascending = false,
|
||||
}) async {
|
||||
try {
|
||||
final List<String> filters = ['list = "$listId"'];
|
||||
|
||||
if (searchQuery.isNotEmpty) {
|
||||
filters.add('name ~ "$searchQuery"');
|
||||
}
|
||||
|
||||
final String sort = ascending ? sortBy : '-$sortBy';
|
||||
|
||||
final result =
|
||||
await AppEngine.engine.pb.collection('collection_item').getList(
|
||||
filter: filters.join(' && '),
|
||||
sort: sort,
|
||||
fields: "file, id, name, type, updated, list, user, created",
|
||||
);
|
||||
|
||||
return result.items.map(
|
||||
(item) {
|
||||
final res = CollectionItemModel.fromJson(item.toJson());
|
||||
|
||||
if (res.file != null) {
|
||||
final url = AppEngine.engine.pb.files.getURL(
|
||||
item,
|
||||
item.getStringValue('file'),
|
||||
);
|
||||
|
||||
res.file = url.toString().replaceFirst(
|
||||
"api/files//",
|
||||
"api/files/pbc_2910457697/",
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
).toList();
|
||||
} catch (e) {
|
||||
print('Error fetching collection items: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new item
|
||||
static Future<CollectionItemModel> addItem({
|
||||
required String listId,
|
||||
required String name,
|
||||
required String type,
|
||||
PlatformFile? file,
|
||||
dynamic content,
|
||||
}) async {
|
||||
final data = {
|
||||
'list': listId,
|
||||
'name': name,
|
||||
'type': type,
|
||||
'content': content,
|
||||
'user': AppEngine.engine.pb.authStore.record!.id,
|
||||
};
|
||||
|
||||
final record =
|
||||
await AppEngine.engine.pb.collection('collection_item').create(
|
||||
body: data,
|
||||
files: [
|
||||
if (file != null)
|
||||
http.MultipartFile.fromBytes(
|
||||
"file",
|
||||
(file.bytes)!.toList(),
|
||||
filename: basename(file.path!),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return CollectionItemModel.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
// Update existing item
|
||||
static Future<CollectionItemModel> updateItem({
|
||||
required String itemId,
|
||||
String? name,
|
||||
String? type,
|
||||
String? file,
|
||||
Map<String, dynamic>? content,
|
||||
}) async {
|
||||
final data = {
|
||||
if (name != null) 'name': name,
|
||||
if (type != null) 'type': type,
|
||||
if (file != null) 'file': file,
|
||||
if (content != null) 'content': content,
|
||||
};
|
||||
|
||||
final record =
|
||||
await AppEngine.engine.pb.collection('collection_item').update(
|
||||
itemId,
|
||||
body: data,
|
||||
);
|
||||
|
||||
return CollectionItemModel.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
// Delete item
|
||||
static Future<void> deleteItem(String itemId) async {
|
||||
await AppEngine.engine.pb.collection('collection_item').delete(itemId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
class CollectionItemModel {
|
||||
final String id;
|
||||
final String name;
|
||||
String? file;
|
||||
final String listId;
|
||||
final String userId;
|
||||
final dynamic content;
|
||||
final String type;
|
||||
final DateTime created;
|
||||
final DateTime updated;
|
||||
|
||||
CollectionItemModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.file,
|
||||
required this.listId,
|
||||
required this.userId,
|
||||
this.content,
|
||||
required this.type,
|
||||
required this.created,
|
||||
required this.updated,
|
||||
});
|
||||
|
||||
factory CollectionItemModel.fromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return CollectionItemModel(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
file: json['file'],
|
||||
listId: json['list'],
|
||||
userId: json['user'],
|
||||
content: json['content'],
|
||||
type: json['type'],
|
||||
created: DateTime.parse(json['created']),
|
||||
updated: DateTime.parse(json['updated']),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
print(e);
|
||||
print(stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
import '../container/collection_list_item_list.dart';
|
||||
|
||||
class CollectionListModel {
|
||||
final String id;
|
||||
final String collectionId;
|
||||
final String name;
|
||||
final String? description;
|
||||
final int order;
|
||||
final String? background;
|
||||
final DateTime created;
|
||||
final DateTime updated;
|
||||
final String userId;
|
||||
final bool isPublic;
|
||||
|
||||
CollectionListModel({
|
||||
required this.id,
|
||||
required this.collectionId,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.order,
|
||||
this.background,
|
||||
required this.created,
|
||||
required this.updated,
|
||||
required this.userId,
|
||||
required this.isPublic,
|
||||
});
|
||||
|
||||
factory CollectionListModel.fromRecord(RecordModel record) {
|
||||
return CollectionListModel(
|
||||
id: record.id,
|
||||
collectionId: record.collectionId,
|
||||
name: record.data['name'],
|
||||
description: record.data['description'],
|
||||
order: record.data['order'],
|
||||
background: record.data['background'],
|
||||
created: DateTime.parse(record.get("created")),
|
||||
updated: DateTime.parse(record.get("updated")),
|
||||
userId: record.data['user'],
|
||||
isPublic: record.data['isPublic'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CollectionCard extends StatefulWidget {
|
||||
final CollectionListModel collection;
|
||||
final double width;
|
||||
|
||||
const CollectionCard({
|
||||
super.key,
|
||||
required this.collection,
|
||||
this.width = 480,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CollectionCard> createState() => _CollectionCardState();
|
||||
}
|
||||
|
||||
class _CollectionCardState extends State<CollectionCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.05).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _controller.forward(),
|
||||
onExit: (_) => _controller.reverse(),
|
||||
child: AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) => Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
height: widget.width * .6,
|
||||
margin: const EdgeInsets.all(12),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black26,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background Image or Gradient
|
||||
_buildBackground(),
|
||||
|
||||
// Gradient Overlay
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.3),
|
||||
Colors.black.withOpacity(0.8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Spacer(),
|
||||
// Collection Name
|
||||
Text(
|
||||
widget.collection.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 3,
|
||||
color: Colors.black45,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Description
|
||||
if (widget.collection.description != null)
|
||||
Text(
|
||||
widget.collection.description!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 2,
|
||||
color: Colors.black45,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Metadata Row
|
||||
Row(
|
||||
children: [
|
||||
_buildMetadataChip(
|
||||
Icons.calendar_today,
|
||||
_formatDate(widget.collection.updated),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildInteractionButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Optional: Add a subtle overlay pattern
|
||||
CustomPaint(
|
||||
painter: PatternPainter(),
|
||||
),
|
||||
|
||||
// Material Ink Effect for Ripple
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => CollectionListItemsScreen(
|
||||
listId: widget.collection.id,
|
||||
title: widget.collection.name,
|
||||
isPublic: widget.collection.isPublic,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackground() {
|
||||
if (widget.collection.background != null) {
|
||||
return Hero(
|
||||
tag: 'collection-${widget.collection.id}',
|
||||
child: Image.network(
|
||||
'${AppEngine.engine.pb.baseURL}/api/files/${widget.collection.collectionId}/${widget.collection.id}/${widget.collection.background}',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildFallbackBackground(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildFallbackBackground();
|
||||
}
|
||||
|
||||
Widget _buildFallbackBackground() {
|
||||
// Generate a unique but consistent color based on collection ID
|
||||
final color = Color(
|
||||
(widget.collection.id.hashCode & 0xFFFFFF) | 0xFF000000,
|
||||
);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
color,
|
||||
Color.lerp(color, Colors.white, 0.2)!,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.collections,
|
||||
size: 80,
|
||||
color: Colors.white24,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataChip(IconData icon, String label) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: Colors.white70),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInteractionButton() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_forward, color: Colors.black87),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
// Handle interaction
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Pattern Painter for subtle overlay
|
||||
class PatternPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withOpacity(0.05)
|
||||
..strokeWidth = 1;
|
||||
|
||||
for (var i = 0; i < size.width; i += 20) {
|
||||
for (var j = 0; j < size.height; j += 20) {
|
||||
canvas.drawCircle(Offset(i.toDouble(), j.toDouble()), 1, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
71
lib/features/common/utils/error_handler.dart
Normal file
71
lib/features/common/utils/error_handler.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
String getErrorMessage(ClientException e) {
|
||||
switch (e.statusCode) {
|
||||
case 400:
|
||||
final data = e.response['data'];
|
||||
|
||||
if (data?['email'] != null) {
|
||||
switch (data['email']['code']) {
|
||||
case 'validation_invalid_email':
|
||||
return 'Please enter a valid email address';
|
||||
case 'validation_not_unique':
|
||||
return 'This email is already registered';
|
||||
default:
|
||||
return 'Invalid email';
|
||||
}
|
||||
}
|
||||
|
||||
if (data?['password'] != null) {
|
||||
switch (data['password']['code']) {
|
||||
case 'validation_length_out_of_range':
|
||||
return 'Password must be at least 6 characters long';
|
||||
case 'validation_too_weak':
|
||||
return 'Password is too weak. Please include numbers and special characters';
|
||||
default:
|
||||
return 'Invalid password';
|
||||
}
|
||||
}
|
||||
|
||||
if (data?['passwordConfirm'] != null) {
|
||||
switch (data['passwordConfirm']['code']) {
|
||||
case 'validation_values_mismatch':
|
||||
return 'Passwords do not match';
|
||||
default:
|
||||
return 'Password confirmation error';
|
||||
}
|
||||
}
|
||||
|
||||
if (data?['username'] != null || data?['name'] != null) {
|
||||
return 'Please enter a valid name';
|
||||
}
|
||||
|
||||
return 'Please check your input and try again';
|
||||
|
||||
case 401:
|
||||
return 'Invalid credentials';
|
||||
|
||||
case 403:
|
||||
return 'You don\'t have permission to perform this action';
|
||||
|
||||
case 404:
|
||||
return 'Resource not found';
|
||||
|
||||
case 429:
|
||||
return 'Too many attempts. Please try again later';
|
||||
|
||||
case 500:
|
||||
return 'Server error. Please try again later';
|
||||
|
||||
case 503:
|
||||
return 'Service temporarily unavailable. Please try again later';
|
||||
|
||||
default:
|
||||
final message = e.response['message'];
|
||||
if (message != null && message.toString().isNotEmpty) {
|
||||
return message.toString();
|
||||
}
|
||||
|
||||
return 'An error occurred. Please try again';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import '../engine/engine.dart';
|
||||
import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart';
|
||||
|
||||
Future<void> refreshAuth() async {
|
||||
final pb = AppEngine.engine.pb;
|
||||
final pb = AppPocketBaseService.instance.pb;
|
||||
final userCollection = pb.collection("users");
|
||||
|
||||
final user = await userCollection.getOne(
|
||||
AppEngine.engine.pb.authStore.record!.id,
|
||||
pb.authStore.record!.id,
|
||||
);
|
||||
pb.authStore.save(pb.authStore.token, user);
|
||||
}
|
||||
42
lib/features/common/utils/startup_app.dart
Normal file
42
lib/features/common/utils/startup_app.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import 'package:cached_query_flutter/cached_query_flutter.dart';
|
||||
import 'package:cached_storage/cached_storage.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../pocketbase/service/pocketbase.service.dart';
|
||||
import '../../widgetter/plugin_base.dart';
|
||||
import '../../widgetter/plugins/stremio/stremio_plugin.dart';
|
||||
|
||||
final _logger = Logger('StartupApp');
|
||||
|
||||
Future startupApp() async {
|
||||
MediaKit.ensureInitialized();
|
||||
|
||||
await AppPocketBaseService.ensureInitialized();
|
||||
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
await windowManager.ensureInitialized();
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
PluginRegistry.instance.reset();
|
||||
}
|
||||
PluginRegistry.instance.registerPlugin(
|
||||
StremioCatalogPlugin(),
|
||||
);
|
||||
|
||||
try {
|
||||
CachedQuery.instance.configFlutter(
|
||||
storage: await CachedStorage.ensureInitialized(),
|
||||
config: QueryConfigFlutter(
|
||||
refetchDuration: const Duration(minutes: 60),
|
||||
cacheDuration: const Duration(minutes: 60),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.warning("Unable initialize cache");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/engine/library.dart';
|
||||
import 'package:madari_client/features/connection/services/stremio_service.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
import '../../settings/types/connection.dart';
|
||||
|
||||
class AutoImport extends StatefulWidget {
|
||||
final Connection item;
|
||||
final VoidCallback? onImport;
|
||||
|
||||
const AutoImport({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.onImport,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AutoImport> createState() => _AutoImportState();
|
||||
}
|
||||
|
||||
class _AutoImportState extends State<AutoImport> {
|
||||
late StremioService _stremio;
|
||||
final List<FolderItem> _selected = [];
|
||||
bool _isLoading = false;
|
||||
bool _selectAll = false;
|
||||
|
||||
Future<List<FolderItem>>? _folders;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initialValueImport();
|
||||
}
|
||||
|
||||
void initialValueImport() {
|
||||
if ("stremio_addons" == widget.item.type) {
|
||||
_stremio = StremioService(
|
||||
connectionId: Future.delayed(
|
||||
Duration.zero,
|
||||
() => widget.item.id,
|
||||
),
|
||||
config: widget.item.config!,
|
||||
);
|
||||
|
||||
_folders = _stremio.getFolders();
|
||||
}
|
||||
}
|
||||
|
||||
void createLibraryInBulk() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
int loaded = 0;
|
||||
for (var item in _selected) {
|
||||
try {
|
||||
await AppEngine.engine.pb.collection("library").create(body: {
|
||||
"title": item.title,
|
||||
"icon": Icons.video_library.codePoint.toString(),
|
||||
"types": ["video"],
|
||||
"user": AppEngine.engine.pb.authStore.record!.id,
|
||||
"config": [item.config ?? item.id],
|
||||
"connection": widget.item.id,
|
||||
});
|
||||
|
||||
loaded += 1;
|
||||
} catch (e, stack) {
|
||||
if (kDebugMode) print("Failed to $e");
|
||||
if (kDebugMode) print(stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"Imported Libraries $loaded failed ${_selected.length - loaded}",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
if (widget.onImport != null) widget.onImport!();
|
||||
}
|
||||
}
|
||||
|
||||
void toggleSelectAll() async {
|
||||
final folders = await _folders;
|
||||
if (folders == null) return;
|
||||
|
||||
setState(() {
|
||||
_selectAll = !_selectAll;
|
||||
if (_selectAll) {
|
||||
_selected.clear();
|
||||
_selected.addAll(folders);
|
||||
} else {
|
||||
_selected.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
title: const Text("Import Libraries"),
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_selectAll
|
||||
? Icons.check_box_outlined
|
||||
: Icons.check_box_outline_blank),
|
||||
onPressed: () {
|
||||
toggleSelectAll();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _selected.isNotEmpty
|
||||
? () {
|
||||
createLibraryInBulk();
|
||||
}
|
||||
: null,
|
||||
label: const Text("Import"),
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: const Icon(Icons.save),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: _folders,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Text("Error: ${snapshot.error}");
|
||||
}
|
||||
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
final folders = snapshot.data!;
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: folders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = folders[index];
|
||||
|
||||
final isSelected =
|
||||
_selected.any((selected) => selected.id == item.id);
|
||||
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selected
|
||||
.removeWhere((selected) => selected.id == item.id);
|
||||
} else {
|
||||
_selected.add(item);
|
||||
}
|
||||
});
|
||||
},
|
||||
leading: isSelected
|
||||
? const Icon(Icons.check)
|
||||
: const Icon(Icons.check_box_outline_blank),
|
||||
title: Text(item.title),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutoImportData {
|
||||
final String id;
|
||||
final String title;
|
||||
|
||||
AutoImportData({
|
||||
required this.id,
|
||||
required this.title,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/engine/connection_type.dart';
|
||||
|
||||
class ConfigureNeoConnection extends StatefulWidget {
|
||||
final ConnectionTypeRecord item;
|
||||
final void Function(String id) onConnectionComplete;
|
||||
|
||||
const ConfigureNeoConnection({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onConnectionComplete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConfigureNeoConnection> createState() => _ConfigureNeoConnectionState();
|
||||
}
|
||||
|
||||
class _ConfigureNeoConnectionState extends State<ConfigureNeoConnection> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _urlController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveConnection() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Connection saved successfully'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// widget.onConnectionComplete("");
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error saving connection: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
prefixIcon: Icon(Icons.sensors_rounded),
|
||||
hintText: 'https://neo.example.com',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter server URL';
|
||||
}
|
||||
if (!(Uri.tryParse(value)?.hasAuthority ?? true)) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter password';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _saveConnection,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save Connection'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:madari_client/engine/connection_type.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
import '../../settings/types/connection.dart';
|
||||
|
||||
class StremioAddonConnection extends StatefulWidget {
|
||||
final void Function(Connection connection) onConnectionComplete;
|
||||
final ConnectionTypeRecord item;
|
||||
|
||||
const StremioAddonConnection({
|
||||
super.key,
|
||||
required this.onConnectionComplete,
|
||||
required this.item,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StremioAddonConnection> createState() => _StremioAddonConnectionState();
|
||||
}
|
||||
|
||||
class _StremioAddonConnectionState extends State<StremioAddonConnection> {
|
||||
final PocketBase pb = AppEngine.engine.pb;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _urlController = TextEditingController();
|
||||
final _nameController = TextEditingController(text: "Stremio");
|
||||
|
||||
static const String cinemetaURL =
|
||||
'https://v3-cinemeta.strem.io/manifest.json';
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
final List<Map<String, dynamic>> _addons = [];
|
||||
|
||||
Future<void> _validateAddonUrl(String url) async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode == 200) {
|
||||
final manifest = json.decode(response.body);
|
||||
|
||||
if (manifest['name'] == null || manifest['id'] == null) {
|
||||
throw 'Invalid addon manifest';
|
||||
}
|
||||
|
||||
if (_addons.any((addon) => addon['url'] == url)) {
|
||||
throw 'Addon already added to the list';
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_addons.add({
|
||||
'name': manifest['name'],
|
||||
'icon': manifest['logo'] ?? manifest['icon'],
|
||||
'url': url,
|
||||
});
|
||||
_urlController.clear();
|
||||
});
|
||||
} else {
|
||||
throw 'Failed to fetch addon manifest';
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is FormatException) {
|
||||
setState(() {
|
||||
_errorMessage = 'Invalid addon URL';
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = 'Invalid addon URL: ${e.toString()}';
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAddons() async {
|
||||
if (!_formKey.currentState!.validate() || _addons.isEmpty) return;
|
||||
|
||||
try {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final body = await pb.collection('connection').create(body: {
|
||||
'title': _nameController.text,
|
||||
'user': pb.authStore.record!.id,
|
||||
'type': widget.item.id,
|
||||
'config': {
|
||||
'addons': _addons
|
||||
.map(
|
||||
(item) => item["url"],
|
||||
)
|
||||
.toList()
|
||||
},
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Saved successfully"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
widget.onConnectionComplete(
|
||||
Connection(
|
||||
title: body.getStringValue("title"),
|
||||
id: body.id,
|
||||
config: jsonEncode({
|
||||
'addons': _addons
|
||||
.map(
|
||||
(item) => item["url"],
|
||||
)
|
||||
.toList()
|
||||
}),
|
||||
type: "stremio_addons",
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is ClientException) {
|
||||
final response = e.response["data"];
|
||||
|
||||
final result = response.values.map((item) => item["message"]).join(" ");
|
||||
|
||||
setState(() {
|
||||
if (kDebugMode) print(result);
|
||||
_errorMessage = "Error: $result";
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_errorMessage = "Error: ${e.toString()}";
|
||||
});
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _removeAddon(int index) {
|
||||
setState(() {
|
||||
_addons.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16.0,
|
||||
bottom: 16.0,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Connection name',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return null;
|
||||
}
|
||||
return "Connection name is required";
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Addon URL',
|
||||
hintText: 'https://example.com/manifest.json',
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _validateAddonUrl(_urlController.text),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (_addons.isEmpty) {
|
||||
return 'Please add at least one addon';
|
||||
}
|
||||
if (value != null && value.isNotEmpty) {
|
||||
try {
|
||||
final uri = Uri.parse(value);
|
||||
if (!uri.isScheme('http') && !uri.isScheme('https')) {
|
||||
return 'Please enter a valid HTTP/HTTPS URL';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||
if (_errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8.0,
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Text(
|
||||
'Suggested Addons:',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 6,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ActionChip.elevated(
|
||||
label: const Text("Cinemeta"),
|
||||
onPressed: () {
|
||||
_validateAddonUrl(cinemetaURL);
|
||||
},
|
||||
avatar: const Icon(Icons.extension),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (_addons.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Added Addons:',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 6,
|
||||
),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _addons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final addon = _addons[index];
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
leading: addon['icon'] != null
|
||||
? Image.network(
|
||||
addon['icon'],
|
||||
width: 40,
|
||||
height: 40,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Icon(Icons.extension),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.extension,
|
||||
size: 40,
|
||||
),
|
||||
title: Text(addon['name']),
|
||||
subtitle: Text(
|
||||
addon['url'],
|
||||
maxLines: 1,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: () => _removeAddon(index),
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _addons.isNotEmpty && !_isLoading ? _saveAddons : null,
|
||||
child: const Text('Save Configuration'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:madari_client/features/connection/containers/auto_import.dart';
|
||||
import 'package:madari_client/features/settings/types/connection.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
import '../../../engine/library.dart';
|
||||
import '../../library/screen/create_new_library.dart';
|
||||
|
||||
class ConnectionManager extends StatefulWidget {
|
||||
final Connection item;
|
||||
|
||||
const ConnectionManager({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConnectionManager> createState() => _ConnectionManagerState();
|
||||
}
|
||||
|
||||
class _ConnectionManagerState extends State<ConnectionManager> {
|
||||
final PocketBase pb = AppEngine.engine.pb;
|
||||
late Future<ResultList<RecordModel>> _items;
|
||||
bool _isLoading = false;
|
||||
bool _isDragging = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshItems();
|
||||
}
|
||||
|
||||
void _refreshItems() {
|
||||
setState(() {
|
||||
_items = pb.collection("library").getList(
|
||||
filter: "connection.id = ${jsonEncode(widget.item.id)}",
|
||||
sort: "+order",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateOrder(
|
||||
int oldIndex, int newIndex, List<RecordModel> items) async {
|
||||
setState(() => _isDragging = true);
|
||||
try {
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
|
||||
final item = items.removeAt(oldIndex);
|
||||
items.insert(newIndex, item);
|
||||
|
||||
// Update order for all affected items
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
await pb.collection("library").update(
|
||||
items[i].id,
|
||||
body: {"order": i},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isDragging = false);
|
||||
_refreshItems();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showEditDialog(RecordModel item) async {
|
||||
final TextEditingController titleController = TextEditingController(
|
||||
text: item.getStringValue("title"),
|
||||
);
|
||||
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: !_isLoading,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Edit Library'),
|
||||
content: TextField(
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(labelText: 'Library Name'),
|
||||
autofocus: true,
|
||||
enabled: !_isLoading,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await pb.collection("library").update(
|
||||
item.id,
|
||||
body: {"title": titleController.text},
|
||||
);
|
||||
if (context.mounted) if (mounted) Navigator.pop(context);
|
||||
_refreshItems();
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(RecordModel item) async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: !_isLoading,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirm Delete'),
|
||||
content: const Text('Are you sure you want to delete this library?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await pb.collection("library").delete(item.id);
|
||||
if (mounted && context.mounted) Navigator.pop(context);
|
||||
_refreshItems();
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Connection ${widget.item.title}"),
|
||||
actions: [
|
||||
const SizedBox(width: 6),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isDragging || _isLoading
|
||||
? null
|
||||
: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => AutoImport(item: widget.item),
|
||||
).then((_) => _refreshItems());
|
||||
},
|
||||
label: const Text("Auto Import"),
|
||||
icon: const Icon(Icons.auto_awesome),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _isDragging || _isLoading
|
||||
? null
|
||||
: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Consumer(
|
||||
builder: (context, ref, child) => CreateNewLibrary(
|
||||
item: widget.item,
|
||||
onCreatedAnother: () {},
|
||||
onCreated: () {
|
||||
Navigator.pop(context);
|
||||
ref.refresh(libraryListProvider(1).future);
|
||||
_refreshItems();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
label: const Text("Add new library"),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 800),
|
||||
child: FutureBuilder(
|
||||
future: _items,
|
||||
builder: (ctx, result) {
|
||||
if (result.hasError) {
|
||||
return Center(
|
||||
child: Text("Error: ${result.error}"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
final items = result.data!.items;
|
||||
|
||||
return ReorderableListView.builder(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemCount: items.length,
|
||||
onReorder: (oldIndex, newIndex) =>
|
||||
_updateOrder(oldIndex, newIndex, items),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Card(
|
||||
key: ValueKey(item.id),
|
||||
margin:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
item.getStringValue("title"),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: _isDragging || _isLoading
|
||||
? null
|
||||
: () => _showEditDialog(item),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: _isDragging || _isLoading
|
||||
? null
|
||||
: () => _confirmDelete(item),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:madari_client/engine/connection_type.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
class CreateNewConnection extends StatefulWidget {
|
||||
final void Function(ConnectionTypeRecord record)? onCallback;
|
||||
|
||||
const CreateNewConnection({
|
||||
super.key,
|
||||
this.onCallback,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CreateNewConnection> createState() => _CreateNewConnectionState();
|
||||
}
|
||||
|
||||
class _CreateNewConnectionState extends State<CreateNewConnection> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||
minHeight: MediaQuery.of(context).size.height * 0.5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildBottomSheetHandle(),
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final activity = ref.watch(connectionTypeListProvider(page: 1));
|
||||
|
||||
return activity.when(
|
||||
data: (result) => _buildConnectionList(result),
|
||||
error: (error, trace) => _buildError(error),
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomSheetHandle() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
"Add New Connection",
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Select a connection type to configure",
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectionList(ResultList<ConnectionTypeRecord> result) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: result.items.length,
|
||||
itemBuilder: (context, index) => _buildConnectionTile(
|
||||
context,
|
||||
result.items[index],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectionTile(BuildContext context, ConnectionTypeRecord item) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
final callback = widget.onCallback;
|
||||
|
||||
if (callback != null) {
|
||||
callback(item);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: Colors.grey[200]!,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
getIcon(item.icon),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.grey[400],
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(Object error) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red[300],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Unable to load connection types',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Please try again later',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData getIcon(String input) {
|
||||
switch (input) {
|
||||
case "drive_file_move":
|
||||
return Icons.drive_file_move;
|
||||
case "sensors_rounded":
|
||||
return Icons.sensors_rounded;
|
||||
case "telegram":
|
||||
return Icons.telegram;
|
||||
case "video":
|
||||
return Icons.stream;
|
||||
default:
|
||||
return Icons.ac_unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/connection/services/stremio_service.dart';
|
||||
import 'package:madari_client/features/settings/types/connection.dart';
|
||||
|
||||
import '../../../engine/library.dart';
|
||||
import '../services/base_connection_service.dart';
|
||||
|
||||
class FolderSelector extends StatefulWidget {
|
||||
final void Function(List<FolderItem>) onFolderSelected;
|
||||
final Connection item;
|
||||
|
||||
const FolderSelector({
|
||||
super.key,
|
||||
required this.onFolderSelected,
|
||||
required this.item,
|
||||
});
|
||||
|
||||
@override
|
||||
createState() => _FolderSelectorState();
|
||||
}
|
||||
|
||||
class _FolderSelectorState extends State<FolderSelector> {
|
||||
List<FolderItem> _folders = [];
|
||||
final List<FolderItem> _selectedFolder = [];
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
late final BaseConnectionService connectionService;
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<FolderItem> _filteredFolders = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final connectionId = Future.delayed(
|
||||
Duration.zero,
|
||||
() => widget.item.id,
|
||||
);
|
||||
|
||||
switch (widget.item.type) {
|
||||
case "stremio_addons":
|
||||
connectionService = StremioService(
|
||||
connectionId: connectionId,
|
||||
config: widget.item.config ?? "{}",
|
||||
);
|
||||
|
||||
default:
|
||||
throw TypeError();
|
||||
}
|
||||
|
||||
_loadFolders();
|
||||
_searchController.addListener(_filterFolders);
|
||||
}
|
||||
|
||||
void _filterFolders() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredFolders = _folders.where((folder) {
|
||||
return folder.title.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadFolders() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final folders = await _fetchFolders();
|
||||
|
||||
setState(() {
|
||||
_folders = folders;
|
||||
_filteredFolders = _folders;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
_filterFolders();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load folders';
|
||||
_isLoading = false;
|
||||
});
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<FolderItem>> _fetchFolders() async {
|
||||
return connectionService.getFolders();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Select',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search folders...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_errorMessage != null)
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
)
|
||||
else if (_filteredFolders.isEmpty)
|
||||
const Text('No folders available')
|
||||
else
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _filteredFolders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final folder = _filteredFolders[index];
|
||||
|
||||
final selected =
|
||||
_selectedFolder.where((i) => i.id == folder.id).isNotEmpty;
|
||||
|
||||
return ListTile(
|
||||
title: Text(folder.title),
|
||||
leading: folder.icon,
|
||||
trailing: selected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
)
|
||||
: const Icon(Icons.circle_outlined),
|
||||
selected: selected,
|
||||
selectedTileColor: Colors.blue.withOpacity(0.1),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (!selected) {
|
||||
_selectedFolder.add(folder);
|
||||
} else {
|
||||
_selectedFolder.remove(folder);
|
||||
}
|
||||
widget.onFolderSelected(_selectedFolder);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/settings/types/connection.dart';
|
||||
|
||||
import '../../../engine/connection_type.dart';
|
||||
import 'configure_stremio_connection.dart';
|
||||
|
||||
class ShowHandleConnectionType extends StatelessWidget {
|
||||
final ConnectionTypeRecord item;
|
||||
final void Function(Connection id) onFinish;
|
||||
|
||||
const ShowHandleConnectionType({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onFinish,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
// Remove fixed height constraints to allow content to resize
|
||||
maxHeight: MediaQuery.of(context).size.height * .9,
|
||||
minWidth: double.infinity,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: _build(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_build(BuildContext context) {
|
||||
Widget child = Container();
|
||||
|
||||
switch (item.type) {
|
||||
case "stremio_addons":
|
||||
child = StremioAddonConnection(
|
||||
item: item,
|
||||
onConnectionComplete: (id) {
|
||||
onFinish(id);
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildBottomSheetHandle(),
|
||||
_buildHeader(context),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomSheetHandle() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
"Configure connection",
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import 'package:madari_client/engine/engine.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../../engine/library.dart';
|
||||
|
||||
abstract class BaseConnectionService {
|
||||
Future<List<FolderItem>> getFolders();
|
||||
Future<ResultList<LibraryItemList>> getList({
|
||||
int page = 1,
|
||||
required String config,
|
||||
List<LibraryItemList>? lastItem,
|
||||
required List<String> type,
|
||||
String? search,
|
||||
});
|
||||
abstract Future<String> connectionId;
|
||||
|
||||
Future<void> createLibrary({
|
||||
required String title,
|
||||
required String icon,
|
||||
required List<String> types,
|
||||
required String config,
|
||||
}) async {
|
||||
AppEngine.engine.pb.collection("library").create(
|
||||
body: {
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"types": types,
|
||||
"user": AppEngine.engine.pb.authStore.record?.id,
|
||||
"config": config,
|
||||
"connection": connectionId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<List<DocSource>> getItem(LibraryItemList item);
|
||||
}
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:madari_client/engine/library.dart';
|
||||
import 'package:madari_client/features/connection/services/base_connection_service.dart';
|
||||
import 'package:madari_client/features/connection/types/stremio.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
|
||||
part 'stremio_service.g.dart';
|
||||
|
||||
class StremioService extends BaseConnectionService {
|
||||
@override
|
||||
Future<String> connectionId;
|
||||
String config;
|
||||
|
||||
static final Map<String, StremioManifest> _cache = {};
|
||||
|
||||
late StremioConfig configParsed;
|
||||
|
||||
StremioService({
|
||||
required this.connectionId,
|
||||
required this.config,
|
||||
}) {
|
||||
configParsed = StremioConfig.fromJson(jsonDecode(config));
|
||||
|
||||
connectionId.then((item) {
|
||||
AppEngine.engine.pb.collection("connection").getOne(item).then((docs) {
|
||||
configParsed = StremioConfig.fromJson(docs.get("config"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<StremioManifest> getManifest(String url) async {
|
||||
if (_cache.containsKey(url)) {
|
||||
return _cache[url]!;
|
||||
}
|
||||
|
||||
final result = await http.get(Uri.parse(url));
|
||||
final resultFinal = StremioManifest.fromJson(jsonDecode(result.body));
|
||||
_cache[url] = resultFinal;
|
||||
|
||||
return resultFinal;
|
||||
}
|
||||
|
||||
Future<Meta?> getItemMetaById(String type, String id) async {
|
||||
for (final addon in configParsed.addons) {
|
||||
final manifest = await getManifest(addon);
|
||||
|
||||
if (manifest.resources?.contains("meta") != true) {
|
||||
if (kDebugMode) print("ignoring because meta is not there");
|
||||
continue;
|
||||
}
|
||||
|
||||
final ids = manifest.idPrefixes
|
||||
?.firstWhere((item) => id.startsWith(item), orElse: () => "");
|
||||
|
||||
if (ids == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await http.get(
|
||||
Uri.parse("${_getAddonBaseURL(addon)}/meta/$type/$id.json"),
|
||||
);
|
||||
|
||||
return StreamMetaResponse.fromJson(jsonDecode(result.body)).meta;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FolderItem>> getFolders() async {
|
||||
final List<FolderItem> result = [];
|
||||
|
||||
for (final addon in configParsed.addons) {
|
||||
final manifest = await getManifest(addon);
|
||||
|
||||
final List<String> resources = (manifest.resources ?? []).map(
|
||||
(item) {
|
||||
return item.name;
|
||||
},
|
||||
).toList();
|
||||
|
||||
if (resources.contains("catalog") ||
|
||||
manifest.catalogs?.isNotEmpty == true) {
|
||||
for (final item
|
||||
in (manifest.catalogs ?? [] as List<StremioManifestCatalog>)) {
|
||||
result.add(
|
||||
FolderItem(
|
||||
title: item.name == null
|
||||
? "${manifest.name} - ${item.type.capitalize()}".trim()
|
||||
: "${item.type.capitalize()} - ${item.name}",
|
||||
id: "${item.type}-${item.id}",
|
||||
icon: const Icon(Icons.movie),
|
||||
config: jsonEncode(
|
||||
{
|
||||
"type": item.type,
|
||||
"id": "${item.type}-${item.id}",
|
||||
"title":
|
||||
"${item.type} ${item.name?.trim() != "" ? item.name : ""}"
|
||||
.trim(),
|
||||
'addon': addon,
|
||||
'item': item,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<DocSource>> getItem(LibraryItemList item) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResultList<LibraryItemList>> getList({
|
||||
int page = 1,
|
||||
required String config,
|
||||
List<LibraryItemList>? lastItem,
|
||||
required List<String> type,
|
||||
String? search,
|
||||
}) async {
|
||||
final configOutput = jsonDecode(config);
|
||||
|
||||
final List<InternalManifestItemConfig> items = [];
|
||||
|
||||
for (final item in configOutput) {
|
||||
final itemToPush = InternalManifestItemConfig.fromJson(item);
|
||||
items.add(itemToPush);
|
||||
}
|
||||
|
||||
final result = ResultList<LibraryItemList>();
|
||||
result.page = page;
|
||||
result.perPage = 50;
|
||||
result.items = List<LibraryItemList>.empty(growable: true);
|
||||
|
||||
for (final item in items) {
|
||||
String url =
|
||||
"${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}.json";
|
||||
|
||||
if (page != 1) {
|
||||
final skip = result.perPage * (page - 1);
|
||||
|
||||
url =
|
||||
"${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}/skip=${Uri.encodeComponent(skip.toString())}.json";
|
||||
}
|
||||
|
||||
if ((search ?? "").isNotEmpty) {
|
||||
url =
|
||||
"${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}/search=${Uri.encodeComponent(search!)}.json";
|
||||
}
|
||||
|
||||
final httpBody = await http.get(
|
||||
Uri.parse(
|
||||
url,
|
||||
),
|
||||
);
|
||||
|
||||
final meta = StrmioMeta.fromJson(json.decode(httpBody.body));
|
||||
|
||||
for (final meta in meta.metas ?? []) {
|
||||
result.items.add(
|
||||
LibraryItemList(
|
||||
id: meta.id,
|
||||
title: meta.name!,
|
||||
logo: meta.poster,
|
||||
extra: meta.description,
|
||||
config: jsonEncode(meta),
|
||||
popularity: (meta.popularity ?? 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_getAddonBaseURL(String input) {
|
||||
return input.endsWith("/manifest.json")
|
||||
? input.replaceAll("/manifest.json", "")
|
||||
: input;
|
||||
}
|
||||
|
||||
Stream<List<VideoStream>> getStreams(
|
||||
String type,
|
||||
String id, {
|
||||
String? season,
|
||||
String? episode,
|
||||
}) async* {
|
||||
final List<VideoStream> streams = [];
|
||||
|
||||
for (final addon in configParsed.addons) {
|
||||
final addonManifest = await getManifest(addon);
|
||||
|
||||
for (final resource in (addonManifest.resources ?? [])) {
|
||||
if ((resource is String && resource == "stream") ||
|
||||
((resource is ResourceObject) &&
|
||||
resource.types?.contains(type) == true)) {
|
||||
final url =
|
||||
"${_getAddonBaseURL(addon)}/stream/$type/${Uri.encodeComponent(id)}.json";
|
||||
|
||||
final result = await http.get(Uri.parse(url), headers: {});
|
||||
|
||||
final body = StreamResponse.fromJson(jsonDecode(result.body));
|
||||
|
||||
streams.addAll(body.streams);
|
||||
|
||||
yield streams;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return "${this[0].toUpperCase()}${substring(1).toLowerCase()}";
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class InternalManifestItemConfig {
|
||||
final InternalItem item;
|
||||
final String addon;
|
||||
|
||||
InternalManifestItemConfig({
|
||||
required this.item,
|
||||
required this.addon,
|
||||
});
|
||||
|
||||
factory InternalManifestItemConfig.fromJson(Map<String, dynamic> json) =>
|
||||
_$InternalManifestItemConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InternalManifestItemConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class InternalItem {
|
||||
final String id;
|
||||
final String? name;
|
||||
final String type;
|
||||
|
||||
InternalItem({
|
||||
required this.id,
|
||||
this.name,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
factory InternalItem.fromJson(Map<String, dynamic> json) =>
|
||||
_$InternalItemFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InternalItemToJson(this);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export '../../connections/types/stremio/stremio_base.types.dart';
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/engine/connection_type.dart';
|
||||
import 'package:madari_client/engine/engine.dart';
|
||||
import 'package:madari_client/features/connections/service/stremio_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../settings/types/connection.dart';
|
||||
import '../types/base/base.dart';
|
||||
import '../widget/stremio/stremio_create.dart';
|
||||
|
||||
abstract class BaseConnectionService {
|
||||
Widget renderCard(LibraryItem item, String heroPrefix);
|
||||
Widget renderList(LibraryItem item, String heroPrefix);
|
||||
|
||||
static final Map<String, RecordModel> _item = {};
|
||||
|
||||
final String connectionId;
|
||||
|
||||
static Future<LibraryRecordResponse> getLibraries() async {
|
||||
final library =
|
||||
await AppEngine.engine.pb.collection("library").getFullList();
|
||||
|
||||
return LibraryRecordResponse(
|
||||
data: library
|
||||
.map(
|
||||
(item) => LibraryRecord.fromRecord(item),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
factory BaseConnectionService.create(
|
||||
Connection item,
|
||||
ConnectionTypeRecord type,
|
||||
) {
|
||||
switch (type.type) {
|
||||
case "stremio_addons":
|
||||
return StremioConnectionService(
|
||||
connectionId: item.id,
|
||||
config: StremioConfig.fromJson(item.config),
|
||||
);
|
||||
}
|
||||
|
||||
throw ErrorDescription("Connection is not supported");
|
||||
}
|
||||
|
||||
static Future<ConnectionResponse> connectionByIdRaw(
|
||||
String connectionId,
|
||||
) async {
|
||||
RecordModel model_;
|
||||
|
||||
if (_item.containsKey(connectionId)) {
|
||||
model_ = _item[connectionId]!;
|
||||
} else {
|
||||
model_ = await AppEngine.engine.pb
|
||||
.collection("connection")
|
||||
.getOne(connectionId, expand: "type");
|
||||
_item[connectionId] = model_;
|
||||
}
|
||||
|
||||
return ConnectionResponse(
|
||||
connection: Connection.fromRecord(model_),
|
||||
connectionTypeRecord: ConnectionTypeRecord.fromRecord(
|
||||
model_.get<RecordModel>("expand.type"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static BaseConnectionService connectionById(
|
||||
ConnectionResponse connection,
|
||||
) {
|
||||
return BaseConnectionService.create(
|
||||
connection.connection,
|
||||
connection.connectionTypeRecord,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget createTypeWidget(String type, OnSuccessCallback onSuccess) {
|
||||
switch (type) {
|
||||
case "stremio":
|
||||
return const StremioCreateConnection();
|
||||
}
|
||||
|
||||
throw ErrorDescription("Connection is not supported");
|
||||
}
|
||||
|
||||
Future<PaginatedResult<LibraryItem>> getItems(
|
||||
LibraryRecord library, {
|
||||
List<ConnectionFilterItem>? items,
|
||||
int? page,
|
||||
int? perPage,
|
||||
String? cursor,
|
||||
});
|
||||
|
||||
Future<List<LibraryItem>> getBulkItem(
|
||||
List<LibraryItem> ids,
|
||||
);
|
||||
|
||||
Future<List<ConnectionFilter<T>>> getFilters<T>(
|
||||
LibraryRecord library,
|
||||
);
|
||||
|
||||
Future<LibraryItem?> getItemById(LibraryItem id);
|
||||
|
||||
Future<void> getStreams(
|
||||
LibraryItem id, {
|
||||
OnStreamCallback? callback,
|
||||
});
|
||||
|
||||
BaseConnectionService({
|
||||
required this.connectionId,
|
||||
});
|
||||
}
|
||||
|
||||
class StreamList {
|
||||
final String title;
|
||||
final String? description;
|
||||
final DocSource source;
|
||||
final StreamSource? streamSource;
|
||||
|
||||
StreamList({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.source,
|
||||
this.streamSource,
|
||||
});
|
||||
}
|
||||
|
||||
class StreamSource {
|
||||
final String title;
|
||||
final String id;
|
||||
|
||||
StreamSource({
|
||||
required this.title,
|
||||
required this.id,
|
||||
});
|
||||
}
|
||||
|
||||
class ConnectionResponse {
|
||||
final Connection connection;
|
||||
final ConnectionTypeRecord connectionTypeRecord;
|
||||
|
||||
ConnectionResponse({
|
||||
required this.connectionTypeRecord,
|
||||
required this.connection,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"connection": connection,
|
||||
"connectionTypeRecord": connectionTypeRecord,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
typedef OnSuccessCallback = void Function(String connectionId);
|
||||
|
||||
class LibraryRecordResponse extends Jsonable {
|
||||
final List<LibraryRecord> data;
|
||||
|
||||
LibraryRecordResponse({
|
||||
required this.data,
|
||||
});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"data": data.map((item) => item.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ConnectionFilter<T> {
|
||||
final String title;
|
||||
final ConnectionFilterType type;
|
||||
final List<T>? values;
|
||||
|
||||
ConnectionFilter({
|
||||
required this.title,
|
||||
required this.type,
|
||||
this.values,
|
||||
});
|
||||
}
|
||||
|
||||
enum ConnectionFilterType {
|
||||
text,
|
||||
options,
|
||||
}
|
||||
|
||||
class ConnectionFilterItem {
|
||||
final String title;
|
||||
final dynamic value;
|
||||
|
||||
ConnectionFilterItem({
|
||||
required this.title,
|
||||
required this.value,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class LibraryItem extends Jsonable {
|
||||
late final String id;
|
||||
|
||||
LibraryItem({
|
||||
required this.id,
|
||||
});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
abstract class PaginatedResult<T extends LibraryItem> {
|
||||
List<T> get items;
|
||||
bool get hasMore;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"items": items.map((res) => res.toJson()),
|
||||
"hasMore": hasMore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CursorPaginatedResult<T extends LibraryItem>
|
||||
implements PaginatedResult<T> {
|
||||
@override
|
||||
final List<T> items;
|
||||
@override
|
||||
final bool hasMore;
|
||||
final String? nextCursor;
|
||||
|
||||
CursorPaginatedResult({
|
||||
required this.items,
|
||||
required this.hasMore,
|
||||
this.nextCursor,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"items": items.map((res) => res.toJson()),
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PagePaginatedResult implements PaginatedResult {
|
||||
@override
|
||||
final List<LibraryItem> items;
|
||||
@override
|
||||
final bool hasMore;
|
||||
final int totalPages;
|
||||
final int currentPage;
|
||||
|
||||
PagePaginatedResult({
|
||||
required this.items,
|
||||
required this.hasMore,
|
||||
required this.totalPages,
|
||||
required this.currentPage,
|
||||
});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"items": items.map((res) => res.toJson()).toList(),
|
||||
"hasMore": hasMore,
|
||||
"totalPages": totalPages,
|
||||
"currentPage": currentPage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,700 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:cached_query/cached_query.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/connections/types/base/base.dart';
|
||||
import 'package:madari_client/features/connections/widget/stremio/stremio_card.dart';
|
||||
import 'package:madari_client/features/connections/widget/stremio/stremio_list_item.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:madari_client/utils/common.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../connection/services/stremio_service.dart';
|
||||
import '../types/stremio/stremio_base.types.dart';
|
||||
import './base_connection_service.dart';
|
||||
|
||||
part 'stremio_connection_service.g.dart';
|
||||
|
||||
final Map<String, String> manifestCache = {};
|
||||
|
||||
typedef OnStreamCallback = void Function(List<StreamList>? items, Error?);
|
||||
|
||||
class StremioConnectionService extends BaseConnectionService {
|
||||
final StremioConfig config;
|
||||
final Logger _logger = Logger('StremioConnectionService');
|
||||
|
||||
StremioConnectionService({
|
||||
required super.connectionId,
|
||||
required this.config,
|
||||
}) {
|
||||
_logger.info('StremioConnectionService initialized with config: $config');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryItem?> getItemById(LibraryItem id) async {
|
||||
_logger.fine('Fetching item by ID: ${id.id}');
|
||||
return Query<LibraryItem?>(
|
||||
key: "meta_${id.id}",
|
||||
config: QueryConfig(
|
||||
cacheDuration: const Duration(days: 30),
|
||||
refetchDuration: (id as Meta).type == "movie"
|
||||
? const Duration(days: 30)
|
||||
: const Duration(days: 1),
|
||||
),
|
||||
queryFn: () async {
|
||||
for (final addon in config.addons) {
|
||||
_logger.finer('Checking addon: $addon');
|
||||
final manifest = await _getManifest(addon);
|
||||
|
||||
if (manifest.resources == null) {
|
||||
_logger.finer('No resources found in manifest for addon: $addon');
|
||||
continue;
|
||||
}
|
||||
|
||||
List<String> idPrefixes = [];
|
||||
bool isMeta = false;
|
||||
|
||||
for (final item in manifest.resources!) {
|
||||
if (item.name == "meta") {
|
||||
idPrefixes
|
||||
.addAll((item.idPrefix ?? []) + (item.idPrefixes ?? []));
|
||||
isMeta = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMeta) {
|
||||
_logger
|
||||
.finer('No meta resource found in manifest for addon: $addon');
|
||||
continue;
|
||||
}
|
||||
|
||||
final ids = ((manifest.idPrefixes ?? []) + idPrefixes)
|
||||
.firstWhere((item) => id.id.startsWith(item), orElse: () => "");
|
||||
|
||||
if (ids.isEmpty) {
|
||||
_logger.finer('No matching ID prefix found for addon: $addon');
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await http.get(
|
||||
Uri.parse(
|
||||
"${_getAddonBaseURL(addon)}/meta/${id.type}/${id.id}.json"),
|
||||
);
|
||||
|
||||
final item = jsonDecode(result.body);
|
||||
|
||||
if (item['meta'] == null) {
|
||||
_logger.finer(
|
||||
'No meta data found for item: ${id.id} in addon: $addon');
|
||||
return null;
|
||||
}
|
||||
|
||||
return StreamMetaResponse.fromJson(item).meta;
|
||||
}
|
||||
|
||||
_logger.warning('No meta data found for item: ${id.id} in any addon');
|
||||
return null;
|
||||
},
|
||||
)
|
||||
.stream
|
||||
.where((item) => item.status != QueryStatus.loading)
|
||||
.first
|
||||
.then((docs) {
|
||||
if (docs.error != null) {
|
||||
_logger.severe('Error fetching item by ID: ${docs.error}');
|
||||
throw docs.error!;
|
||||
}
|
||||
return docs.data;
|
||||
});
|
||||
}
|
||||
|
||||
List<InternalManifestItemConfig> getConfig(dynamic configOutput) {
|
||||
_logger.fine('Parsing config output');
|
||||
final List<InternalManifestItemConfig> configItems = [];
|
||||
|
||||
for (final item in configOutput) {
|
||||
final itemToPush = InternalManifestItemConfig.fromJson(
|
||||
jsonDecode(item),
|
||||
);
|
||||
configItems.add(itemToPush);
|
||||
}
|
||||
|
||||
_logger.finer('Config parsed successfully: $configItems');
|
||||
return configItems;
|
||||
}
|
||||
|
||||
Stream<List<Subtitle>> getSubtitles(Meta record) async* {
|
||||
final List<Subtitle> subtitles = [];
|
||||
|
||||
_logger.info('getting subtitles');
|
||||
|
||||
for (final addon in config.addons) {
|
||||
final manifest = await _getManifest(addon);
|
||||
|
||||
final resource = manifest.resources
|
||||
?.firstWhereOrNull((res) => res.name == "subtitles");
|
||||
|
||||
if (resource == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final types = resource.types ?? manifest.types ?? [];
|
||||
final idPrefixes =
|
||||
resource.idPrefixes ?? resource.idPrefix ?? manifest.idPrefixes;
|
||||
|
||||
if (!types.contains(record.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final hasPrefixMatch = idPrefixes?.firstWhereOrNull((item) {
|
||||
return record.id.startsWith(item);
|
||||
});
|
||||
|
||||
if (hasPrefixMatch == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final addonBase = _getAddonBaseURL(addon);
|
||||
|
||||
final url =
|
||||
"$addonBase/subtitles/${record.type}/${Uri.encodeQueryComponent(record.currentVideo?.id ?? record.id)}.json";
|
||||
|
||||
_logger.info('loading subtitles from $url');
|
||||
|
||||
final body = await http.get(Uri.parse(url));
|
||||
|
||||
if (body.statusCode != 200) {
|
||||
_logger.warning('failed due to status code ${body.statusCode}');
|
||||
continue;
|
||||
}
|
||||
|
||||
final dataBody = jsonDecode(body.body);
|
||||
|
||||
try {
|
||||
final responses = SubtitleResponse.fromJson(dataBody);
|
||||
subtitles.addAll(responses.subtitles);
|
||||
yield subtitles;
|
||||
} catch (e) {
|
||||
_logger.warning("failed to parse subtitle response");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<LibraryItem>> getItems(
|
||||
LibraryRecord library, {
|
||||
List<ConnectionFilterItem>? items,
|
||||
int? page,
|
||||
int? perPage,
|
||||
String? cursor,
|
||||
}) async {
|
||||
_logger.fine('Fetching items for library: ${library.id}');
|
||||
final List<Meta> returnValue = [];
|
||||
final configItems = getConfig(library.config);
|
||||
|
||||
bool hasMore = false;
|
||||
|
||||
const perPage = 50;
|
||||
|
||||
items = [...(items ?? [])];
|
||||
|
||||
if (page != null) {
|
||||
items.add(
|
||||
ConnectionFilterItem(
|
||||
title: "skip",
|
||||
value: page * perPage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final item in configItems) {
|
||||
String url =
|
||||
"${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}";
|
||||
|
||||
if (items.isNotEmpty) {
|
||||
String filterPath = items.map((filter) {
|
||||
return "${filter.title}=${Uri.encodeComponent(filter.value.toString())}";
|
||||
}).join('&');
|
||||
|
||||
if (filterPath.isNotEmpty) {
|
||||
url += "/$filterPath";
|
||||
}
|
||||
}
|
||||
|
||||
url += ".json";
|
||||
|
||||
final result = await Query(
|
||||
config: QueryConfig(
|
||||
cacheDuration: const Duration(hours: 8),
|
||||
),
|
||||
queryFn: () async {
|
||||
try {
|
||||
_logger.finer('Fetching catalog from URL: $url');
|
||||
final httpBody = await http.get(Uri.parse(url));
|
||||
return StrmioMeta.fromJson(
|
||||
jsonDecode(httpBody.body),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_logger.severe('Error parsing catalog $url', e, stack);
|
||||
rethrow;
|
||||
}
|
||||
},
|
||||
key: url,
|
||||
)
|
||||
.stream
|
||||
.where((item) => item.status != QueryStatus.loading)
|
||||
.first
|
||||
.then((docs) {
|
||||
if (docs.error != null) {
|
||||
_logger.severe('Error fetching catalog', docs.error);
|
||||
throw docs.error!;
|
||||
}
|
||||
return docs.data!;
|
||||
});
|
||||
|
||||
hasMore = result.hasMore ?? false;
|
||||
returnValue.addAll(result.metas ?? []);
|
||||
}
|
||||
|
||||
_logger.finer('Items fetched successfully: ${returnValue.length} items');
|
||||
return PagePaginatedResult(
|
||||
items: returnValue.toList(),
|
||||
currentPage: page ?? 1,
|
||||
totalPages: 0,
|
||||
hasMore: hasMore,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget renderCard(LibraryItem item, String heroPrefix) {
|
||||
_logger.fine('Rendering card for item: ${item.id}');
|
||||
return StremioCard(
|
||||
item: item,
|
||||
prefix: heroPrefix,
|
||||
connectionId: connectionId,
|
||||
service: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<LibraryItem>> getBulkItem(List<LibraryItem> ids) async {
|
||||
_logger.fine('Fetching bulk items: ${ids.length} items');
|
||||
if (ids.isEmpty) {
|
||||
_logger.finer('No items to fetch');
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await Future.wait(
|
||||
ids.map(
|
||||
(res) async {
|
||||
return getItemById(res).then((item) {
|
||||
if (item == null) {
|
||||
_logger.finer('Item not found: ${res.id}');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (item as Meta).copyWith(
|
||||
progress: (res as Meta).progress,
|
||||
selectedVideoIndex: res.selectedVideoIndex,
|
||||
);
|
||||
}).catchError((err, stack) {
|
||||
_logger.severe('Error fetching item: ${res.id}', err, stack);
|
||||
return (res as Meta);
|
||||
});
|
||||
},
|
||||
),
|
||||
))
|
||||
.whereType<Meta>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget renderList(LibraryItem item, String heroPrefix) {
|
||||
_logger.fine('Rendering list item: ${item.id}');
|
||||
return StremioListItem(item: item);
|
||||
}
|
||||
|
||||
Future<StremioManifest> _getManifest(String url) async {
|
||||
_logger.fine('Fetching manifest from URL: $url');
|
||||
return Query(
|
||||
key: url,
|
||||
config: QueryConfig(
|
||||
cacheDuration: const Duration(days: 30),
|
||||
refetchDuration: const Duration(days: 1),
|
||||
),
|
||||
queryFn: () async {
|
||||
final String result;
|
||||
if (manifestCache.containsKey(url)) {
|
||||
_logger.finer('Manifest found in cache for URL: $url');
|
||||
result = manifestCache[url]!;
|
||||
} else {
|
||||
_logger.finer('Fetching manifest from network for URL: $url');
|
||||
result = (await http.get(Uri.parse(url))).body;
|
||||
manifestCache[url] = result;
|
||||
}
|
||||
|
||||
final body = jsonDecode(result);
|
||||
final resultFinal = StremioManifest.fromJson(body);
|
||||
_logger.finer('Manifest successfully parsed for URL: $url');
|
||||
return resultFinal;
|
||||
},
|
||||
)
|
||||
.stream
|
||||
.where((item) => item.status != QueryStatus.loading)
|
||||
.first
|
||||
.then((docs) {
|
||||
if (docs.error != null) {
|
||||
_logger.severe('Error fetching manifest: ${docs.error}');
|
||||
throw docs.error!;
|
||||
}
|
||||
return docs.data!;
|
||||
});
|
||||
}
|
||||
|
||||
String _getAddonBaseURL(String input) {
|
||||
return input.endsWith("/manifest.json")
|
||||
? input.replaceAll("/manifest.json", "")
|
||||
: input;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ConnectionFilter<T>>> getFilters<T>(LibraryRecord library) async {
|
||||
_logger.fine('Fetching filters for library: ${library.id}');
|
||||
final configItems = getConfig(library.config);
|
||||
List<ConnectionFilter<T>> filters = [];
|
||||
|
||||
try {
|
||||
for (final addon in configItems) {
|
||||
final addonManifest = await _getManifest(addon.addon);
|
||||
|
||||
if ((addonManifest.catalogs?.isEmpty ?? true) == true) {
|
||||
_logger.finer('No catalogs found for addon: ${addon.addon}');
|
||||
continue;
|
||||
}
|
||||
|
||||
final catalogs = addonManifest.catalogs!.where((item) {
|
||||
return item.id == addon.item.id && item.type == addon.item.type;
|
||||
}).toList();
|
||||
|
||||
for (final catalog in catalogs) {
|
||||
if (catalog.extra == null) {
|
||||
_logger.finer('No extra filters found for catalog: ${catalog.id}');
|
||||
continue;
|
||||
}
|
||||
for (final extraItem in catalog.extra!) {
|
||||
if (extraItem.options == null ||
|
||||
extraItem.options?.isEmpty == true) {
|
||||
filters.add(
|
||||
ConnectionFilter<T>(
|
||||
title: extraItem.name,
|
||||
type: ConnectionFilterType.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
filters.add(
|
||||
ConnectionFilter<T>(
|
||||
title: extraItem.name,
|
||||
type: ConnectionFilterType.options,
|
||||
values: extraItem.options?.whereType<T>().toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe('Error fetching filters', e);
|
||||
}
|
||||
|
||||
_logger.finer('Filters fetched successfully: $filters');
|
||||
return filters;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStreams(
|
||||
LibraryItem id, {
|
||||
OnStreamCallback? callback,
|
||||
}) async {
|
||||
_logger.fine('Fetching streams for item: ${id.id}');
|
||||
final List<StreamList> streams = [];
|
||||
final meta = id as Meta;
|
||||
|
||||
final List<Future<void>> promises = [];
|
||||
|
||||
for (final addon in config.addons) {
|
||||
final future = Future.delayed(const Duration(seconds: 0), () async {
|
||||
final addonManifest = await _getManifest(addon);
|
||||
|
||||
for (final resource_ in (addonManifest.resources ?? [])) {
|
||||
final resource = resource_ as ResourceObject;
|
||||
|
||||
if (!doesAddonSupportStream(resource, addonManifest, meta)) {
|
||||
_logger.finer(
|
||||
'Addon does not support stream: ${addonManifest.name}',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
final url =
|
||||
"${_getAddonBaseURL(addon)}/stream/${meta.type}/${Uri.encodeComponent(id.currentVideo?.id ?? id.id)}.json";
|
||||
|
||||
final result = await Query(
|
||||
key: url,
|
||||
queryFn: () async {
|
||||
final result = await http.get(Uri.parse(url), headers: {});
|
||||
|
||||
if (result.statusCode == 404) {
|
||||
_logger.warning(
|
||||
'Invalid status code for addon: ${addonManifest.name}',
|
||||
);
|
||||
if (callback != null) {
|
||||
callback(
|
||||
null,
|
||||
ArgumentError(
|
||||
"Invalid status code for the addon ${addonManifest.name} with id ${addonManifest.id}",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result.body;
|
||||
},
|
||||
)
|
||||
.stream
|
||||
.where((item) => item.status != QueryStatus.loading)
|
||||
.first
|
||||
.then((docs) {
|
||||
return docs.data;
|
||||
});
|
||||
|
||||
if (result == null) {
|
||||
_logger.finer('No stream data found for URL: $url');
|
||||
continue;
|
||||
}
|
||||
|
||||
final body = StreamResponse.fromJson(jsonDecode(result));
|
||||
|
||||
streams.addAll(
|
||||
body.streams
|
||||
.map(
|
||||
(item) => videoStreamToStreamList(
|
||||
item,
|
||||
meta,
|
||||
addonManifest,
|
||||
),
|
||||
)
|
||||
.whereType<StreamList>()
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (callback != null) {
|
||||
callback(streams, null);
|
||||
}
|
||||
}
|
||||
}).catchError((error, stacktrace) {
|
||||
_logger.severe('Error fetching streams', error, stacktrace);
|
||||
if (callback != null) callback(null, error);
|
||||
});
|
||||
|
||||
promises.add(future);
|
||||
}
|
||||
|
||||
await Future.wait(promises);
|
||||
_logger.finer('Streams fetched successfully: ${streams.length} streams');
|
||||
return;
|
||||
}
|
||||
|
||||
bool doesAddonSupportStream(
|
||||
ResourceObject resource,
|
||||
StremioManifest addonManifest,
|
||||
Meta meta,
|
||||
) {
|
||||
if (resource.name != "stream") {
|
||||
_logger.finer('Resource is not a stream: ${resource.name}');
|
||||
return false;
|
||||
}
|
||||
|
||||
final idPrefixes =
|
||||
resource.idPrefixes ?? addonManifest.idPrefixes ?? resource.idPrefix;
|
||||
|
||||
final types = resource.types ?? addonManifest.types;
|
||||
|
||||
if (types == null || !types.contains(meta.type)) {
|
||||
_logger.finer('Addon does not support type: ${meta.type}');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((idPrefixes ?? []).isEmpty == true) {
|
||||
_logger.finer('No ID prefixes found, assuming support');
|
||||
return true;
|
||||
}
|
||||
|
||||
final hasIdPrefix = (idPrefixes ?? []).where(
|
||||
(item) => meta.id.startsWith(item),
|
||||
);
|
||||
|
||||
if (hasIdPrefix.isEmpty) {
|
||||
_logger.finer('No matching ID prefix found');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.finer('Addon supports stream');
|
||||
return true;
|
||||
}
|
||||
|
||||
StreamList? videoStreamToStreamList(
|
||||
VideoStream item,
|
||||
Meta meta,
|
||||
StremioManifest addonManifest,
|
||||
) {
|
||||
String streamTitle = (item.name != null
|
||||
? "${(item.name ?? "")} ${(item.title ?? "")}"
|
||||
: item.title) ??
|
||||
"No title";
|
||||
|
||||
try {
|
||||
streamTitle = utf8.decode(streamTitle.runes.toList());
|
||||
} catch (e) {}
|
||||
|
||||
String? streamDescription = item.description;
|
||||
|
||||
try {
|
||||
streamDescription = item.description != null
|
||||
? utf8.decode((item.description!).runes.toList())
|
||||
: null;
|
||||
} catch (e) {}
|
||||
|
||||
String title = meta.name ?? item.title ?? "No title";
|
||||
|
||||
DocSource? source;
|
||||
|
||||
if (item.url != null) {
|
||||
source = MediaURLSource(
|
||||
title: title,
|
||||
url: item.url!,
|
||||
id: meta.id,
|
||||
);
|
||||
}
|
||||
|
||||
if (item.infoHash != null) {
|
||||
source = TorrentSource(
|
||||
title: title,
|
||||
infoHash: item.infoHash!,
|
||||
id: meta.id,
|
||||
fileName: "$title.mp4",
|
||||
);
|
||||
}
|
||||
|
||||
if (source == null) {
|
||||
_logger.finer('No valid source found for stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
String addonName = addonManifest.name;
|
||||
|
||||
try {
|
||||
addonName = utf8.decode((addonName).runes.toList());
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to decode addon name', e);
|
||||
}
|
||||
|
||||
_logger.finer('Stream list created successfully');
|
||||
return StreamList(
|
||||
title: streamTitle,
|
||||
description: streamDescription,
|
||||
source: source,
|
||||
streamSource: StreamSource(
|
||||
title: addonName,
|
||||
id: addonManifest.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class StremioConfig {
|
||||
List<String> addons;
|
||||
|
||||
StremioConfig({
|
||||
required this.addons,
|
||||
});
|
||||
|
||||
factory StremioConfig.fromRecord(RecordModel record) =>
|
||||
StremioConfig.fromJson(record.toJson());
|
||||
|
||||
factory StremioConfig.fromJson(Map<String, dynamic> json) =>
|
||||
_$StremioConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$StremioConfigToJson(this);
|
||||
}
|
||||
|
||||
class Subtitle {
|
||||
final String id;
|
||||
final String url;
|
||||
final String? subEncoding;
|
||||
final String? lang;
|
||||
final String? m;
|
||||
final String? g; // Making g optional since some entries have empty string
|
||||
|
||||
const Subtitle({
|
||||
required this.id,
|
||||
required this.url,
|
||||
required this.subEncoding,
|
||||
required this.lang,
|
||||
required this.m,
|
||||
this.g,
|
||||
});
|
||||
|
||||
factory Subtitle.fromJson(Map<String, dynamic> json) {
|
||||
return Subtitle(
|
||||
id: json['id'] as String,
|
||||
url: json['url'] as String,
|
||||
subEncoding: json['SubEncoding'] as String?,
|
||||
lang: json['lang'] as String?,
|
||||
m: json['m'] as String?,
|
||||
g: json['g'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'SubEncoding': subEncoding,
|
||||
'lang': lang,
|
||||
'm': m,
|
||||
'g': g,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SubtitleResponse {
|
||||
final List<Subtitle> subtitles;
|
||||
final int? cacheMaxAge;
|
||||
|
||||
const SubtitleResponse({
|
||||
required this.subtitles,
|
||||
required this.cacheMaxAge,
|
||||
});
|
||||
|
||||
factory SubtitleResponse.fromJson(Map<String, dynamic> json) {
|
||||
return SubtitleResponse(
|
||||
subtitles: (json['subtitles'] as List)
|
||||
.map((e) => Subtitle.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
cacheMaxAge: json['cacheMaxAge'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'subtitles': subtitles.map((e) => e.toJson()).toList(),
|
||||
'cacheMaxAge': cacheMaxAge,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
part 'base.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class LibraryRecord extends Jsonable {
|
||||
final String id;
|
||||
final String icon;
|
||||
final String title;
|
||||
final List<String> types;
|
||||
final dynamic config;
|
||||
final String connection;
|
||||
final String connectionType;
|
||||
|
||||
LibraryRecord({
|
||||
required this.id,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.types,
|
||||
required this.config,
|
||||
required this.connection,
|
||||
required this.connectionType,
|
||||
});
|
||||
|
||||
factory LibraryRecord.fromRecord(RecordModel record) =>
|
||||
LibraryRecord.fromJson(record.toJson());
|
||||
|
||||
factory LibraryRecord.fromJson(Map<String, dynamic> json) =>
|
||||
_$LibraryRecordFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$LibraryRecordToJson(this);
|
||||
}
|
||||
|
|
@ -1,609 +0,0 @@
|
|||
import 'package:cached_query_flutter/cached_query_flutter.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:madari_client/engine/engine.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/connections/types/base/base.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../../utils/grid.dart';
|
||||
import '../stremio/stremio_filter.dart';
|
||||
|
||||
final pb = AppEngine.engine.pb;
|
||||
|
||||
class RenderLibraryList extends StatefulWidget {
|
||||
final LibraryRecord item;
|
||||
final List<ConnectionFilterItem> filters;
|
||||
final bool isGrid;
|
||||
|
||||
const RenderLibraryList({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.filters,
|
||||
this.isGrid = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RenderLibraryList> createState() => _RenderLibraryListState();
|
||||
}
|
||||
|
||||
class _RenderLibraryListState extends State<RenderLibraryList> {
|
||||
late final query = Query(
|
||||
key: widget.item.id,
|
||||
queryFn: () => BaseConnectionService.connectionByIdRaw(
|
||||
widget.item.connection,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QueryBuilder(
|
||||
query: query,
|
||||
builder: (ctx, state) {
|
||||
if (state.status == QueryStatus.loading) {
|
||||
return const Center(
|
||||
child: SpinnerCards(),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == QueryStatus.error) {
|
||||
final errorMessage = (
|
||||
state.error is ClientException
|
||||
? (state.error as ClientException).response["message"]
|
||||
: "",
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: getListHeight(context),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
color: Colors.black45,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"Something went wrong while loading library\n${errorMessage.$1}",
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.exo2().copyWith(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return _RenderLibraryList(
|
||||
item: widget.item,
|
||||
service: state.data!,
|
||||
filters: widget.filters,
|
||||
isGrid: widget.isGrid,
|
||||
);
|
||||
} catch (e) {
|
||||
return Text("Error $e");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderLibraryList extends StatefulWidget {
|
||||
final LibraryRecord item;
|
||||
final ConnectionResponse service;
|
||||
final List<ConnectionFilterItem> filters;
|
||||
final bool isGrid;
|
||||
|
||||
const _RenderLibraryList({
|
||||
required this.item,
|
||||
required this.service,
|
||||
required this.filters,
|
||||
required this.isGrid,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RenderLibraryList> createState() => __RenderLibraryListState();
|
||||
}
|
||||
|
||||
class __RenderLibraryListState extends State<_RenderLibraryList> {
|
||||
late BaseConnectionService service = BaseConnectionService.connectionById(
|
||||
widget.service,
|
||||
);
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
bool get _isBottom {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
final maxScroll = _scrollController.position.maxScrollExtent;
|
||||
final currentScroll = _scrollController.offset;
|
||||
return currentScroll >= (maxScroll * 0.9);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_isBottom && query.state.status != QueryStatus.loading) {
|
||||
query.getNextPage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
query = getQuery();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
loadFilters();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController
|
||||
..removeListener(_onScroll)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<ConnectionFilterItem> filters = [];
|
||||
|
||||
InfiniteQuery getQuery() {
|
||||
return InfiniteQuery<List<LibraryItem>, int>(
|
||||
key:
|
||||
"loadLibrary${widget.item.id}${(widget.filters + filters).map((res) => "${res.title}=${res.value}").join("&")}",
|
||||
queryFn: (page) {
|
||||
return service
|
||||
.getItems(
|
||||
widget.item,
|
||||
items: widget.filters + filters,
|
||||
page: page,
|
||||
)
|
||||
.then((docs) {
|
||||
return docs.items.toList();
|
||||
}).catchError((e, stack) {
|
||||
throw e;
|
||||
});
|
||||
},
|
||||
getNextArg: (state) {
|
||||
if (state.lastPage?.isEmpty ?? false) return null;
|
||||
return state.length;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late InfiniteQuery query = getQuery();
|
||||
|
||||
bool isUnsupported = false;
|
||||
|
||||
loadFilters() async {
|
||||
final filters = await service.getFilters(widget.item);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
filterList = filters;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<ConnectionFilter>? filterList;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.isGrid) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.item.title),
|
||||
),
|
||||
body: SizedBox(
|
||||
height: MediaQuery.of(context).size.height - 96,
|
||||
child: Flex(
|
||||
direction: Axis.vertical,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
if (filterList == null)
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 36,
|
||||
width: 120,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 10.0,
|
||||
right: 10.0,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const SizedBox(
|
||||
height: 36,
|
||||
width: 120,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (filterList != null)
|
||||
InlineFilters(
|
||||
filters: filterList ?? [],
|
||||
filterCallback: (item) {
|
||||
filters = item;
|
||||
|
||||
setState(() {
|
||||
query = getQuery();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height - 96,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 10.0,
|
||||
right: 10.0,
|
||||
),
|
||||
child: _buildBody(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildBody();
|
||||
}
|
||||
|
||||
_buildBody() {
|
||||
final listHeight = getListHeight(context);
|
||||
|
||||
if (isUnsupported) {
|
||||
return SizedBox(
|
||||
height: listHeight,
|
||||
child: const Text("This connection is not supported "),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: listHeight,
|
||||
child: InfiniteQueryBuilder(
|
||||
query: query,
|
||||
builder: (context, data, query) {
|
||||
final items = (data.data?.expand((e) => e).toList() ?? [])
|
||||
.whereType<LibraryItem>()
|
||||
.toList();
|
||||
|
||||
return RenderListItems(
|
||||
hasError: data.status == QueryStatus.error,
|
||||
onRefresh: () {
|
||||
query.refetch();
|
||||
},
|
||||
loadMore: () {
|
||||
query.getNextPage();
|
||||
},
|
||||
itemScrollController: _scrollController,
|
||||
isLoadingMore: data.status == QueryStatus.loading && items.isEmpty,
|
||||
isGrid: widget.isGrid,
|
||||
items: items,
|
||||
heroPrefix: widget.item.id,
|
||||
service: service,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef OnContextTap = void Function(
|
||||
String actionId,
|
||||
LibraryItem item,
|
||||
);
|
||||
|
||||
class ContextMenuItem {
|
||||
final String id;
|
||||
final String title;
|
||||
final bool isDefaultAction;
|
||||
final bool isDestructiveAction;
|
||||
final IconData? icon;
|
||||
final OnContextTap? onCallback;
|
||||
|
||||
ContextMenuItem({
|
||||
required this.title,
|
||||
this.isDefaultAction = false,
|
||||
this.isDestructiveAction = false,
|
||||
this.icon,
|
||||
required this.id,
|
||||
this.onCallback,
|
||||
});
|
||||
}
|
||||
|
||||
class RenderListItems extends StatefulWidget {
|
||||
final ScrollController? controller;
|
||||
final ScrollController? itemScrollController;
|
||||
final bool isGrid;
|
||||
final bool hasError;
|
||||
final VoidCallback? onRefresh;
|
||||
final BaseConnectionService service;
|
||||
final List<LibraryItem> items;
|
||||
final String heroPrefix;
|
||||
final dynamic error;
|
||||
final bool isWide;
|
||||
final bool isLoadingMore;
|
||||
final VoidCallback? loadMore;
|
||||
final List<ContextMenuItem> contextMenuItems;
|
||||
final OnContextTap? onContextMenu;
|
||||
|
||||
const RenderListItems({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.isGrid = false,
|
||||
this.hasError = false,
|
||||
this.onRefresh,
|
||||
required this.items,
|
||||
required this.service,
|
||||
required this.heroPrefix,
|
||||
this.itemScrollController,
|
||||
this.error,
|
||||
this.isWide = false,
|
||||
this.isLoadingMore = false,
|
||||
this.loadMore,
|
||||
this.contextMenuItems = const [],
|
||||
this.onContextMenu,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RenderListItems> createState() => _RenderListItemsState();
|
||||
}
|
||||
|
||||
class _RenderListItemsState extends State<RenderListItems> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final listHeight = getListHeight(context);
|
||||
final itemWidth = getItemWidth(
|
||||
context,
|
||||
isWide: widget.isWide,
|
||||
);
|
||||
|
||||
return CustomScrollView(
|
||||
controller: widget.controller,
|
||||
physics: widget.isGrid
|
||||
? const AlwaysScrollableScrollPhysics()
|
||||
: const NeverScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
if (widget.hasError)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: listHeight,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Something went wrong while loading the library \n${widget.error}",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
TextButton.icon(
|
||||
label: const Text("Retry"),
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(
|
||||
Icons.refresh,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isGrid) ...[
|
||||
SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: getGridResponsiveColumnCount(context),
|
||||
mainAxisSpacing: getGridResponsiveSpacing(context),
|
||||
crossAxisSpacing: getGridResponsiveSpacing(context),
|
||||
childAspectRatio: 2 / 3,
|
||||
),
|
||||
itemCount: widget.items.length,
|
||||
itemBuilder: (ctx, index) {
|
||||
final item = widget.items[index];
|
||||
|
||||
return widget.service.renderCard(
|
||||
item,
|
||||
"${index}_${widget.heroPrefix}",
|
||||
);
|
||||
},
|
||||
),
|
||||
if (widget.isLoadingMore)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8.0,
|
||||
right: 8.0,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: getGridResponsiveColumnCount(context),
|
||||
mainAxisSpacing: getGridResponsiveSpacing(context),
|
||||
crossAxisSpacing: getGridResponsiveSpacing(context),
|
||||
childAspectRatio: 2 / 3,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: Colors.grey[800]!,
|
||||
highlightColor: Colors.grey[700]!,
|
||||
child: Container(
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: 4, // Fixed number of loading items
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
if (!widget.isLoadingMore)
|
||||
SliverToBoxAdapter(
|
||||
child: CupertinoPageScaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
child: SizedBox(
|
||||
height: listHeight,
|
||||
child: ListView.builder(
|
||||
controller: widget.itemScrollController,
|
||||
itemBuilder: (ctx, index) {
|
||||
final item = widget.items[index];
|
||||
|
||||
if (widget.contextMenuItems.isEmpty) {
|
||||
return SizedBox(
|
||||
width: itemWidth,
|
||||
child: Container(
|
||||
child: widget.service.renderCard(
|
||||
item,
|
||||
"${index}_${widget.heroPrefix}",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return CupertinoContextMenu(
|
||||
enableHapticFeedback: true,
|
||||
actions: widget.contextMenuItems.map((menu) {
|
||||
return CupertinoContextMenuAction(
|
||||
isDefaultAction: menu.isDefaultAction,
|
||||
isDestructiveAction: menu.isDestructiveAction,
|
||||
trailingIcon: menu.icon,
|
||||
onPressed: () {
|
||||
if (widget.onContextMenu != null) {
|
||||
widget.onContextMenu!(
|
||||
menu.id,
|
||||
item,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(menu.title),
|
||||
);
|
||||
}).toList(),
|
||||
child: SizedBox(
|
||||
width: itemWidth,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: listHeight,
|
||||
),
|
||||
child: widget.service.renderCard(
|
||||
item,
|
||||
"${index}_${widget.heroPrefix}",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.items.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isLoadingMore)
|
||||
SliverToBoxAdapter(
|
||||
child: SpinnerCards(
|
||||
isWide: widget.isWide,
|
||||
),
|
||||
),
|
||||
],
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpinnerCards extends StatelessWidget {
|
||||
final bool isWide;
|
||||
const SpinnerCards({
|
||||
super.key,
|
||||
this.isWide = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final itemWidth = getItemWidth(
|
||||
context,
|
||||
isWide: isWide,
|
||||
);
|
||||
final itemHeight = getListHeight(context);
|
||||
|
||||
return SizedBox(
|
||||
height: itemHeight,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, _) {
|
||||
return SizedBox(
|
||||
width: itemWidth,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(
|
||||
right: 8,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: Colors.grey[800]!,
|
||||
highlightColor: Colors.grey[700]!,
|
||||
child: Container(
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: 10,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double getItemWidth(BuildContext context, {bool isWide = false}) {
|
||||
double screenWidth = MediaQuery.of(context).size.width;
|
||||
return screenWidth > 800
|
||||
? (isWide ? 400.0 : 200.0)
|
||||
: (isWide ? 280.0 : 120.0);
|
||||
}
|
||||
|
||||
double getListHeight(BuildContext context) {
|
||||
double screenWidth = MediaQuery.of(context).size.width;
|
||||
return screenWidth > 800 ? 300.0 : 180.0;
|
||||
}
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/connection/types/stremio.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart';
|
||||
|
||||
import '../../../../utils/external_player.dart';
|
||||
import '../../../../utils/load_language.dart';
|
||||
import '../../../doc_viewer/types/doc_source.dart';
|
||||
import '../../../downloads/service/service.dart';
|
||||
|
||||
// Note: This is because there is some conflict between drift and this
|
||||
const kIsWeb = bool.fromEnvironment('dart.library.js_util');
|
||||
|
||||
class RenderStreamList extends StatefulWidget {
|
||||
final BaseConnectionService service;
|
||||
final LibraryItem id;
|
||||
final bool shouldPop;
|
||||
final double? progress;
|
||||
|
||||
const RenderStreamList({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.id,
|
||||
this.progress,
|
||||
required this.shouldPop,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RenderStreamList> createState() => _RenderStreamListState();
|
||||
}
|
||||
|
||||
class _RenderStreamListState extends State<RenderStreamList> {
|
||||
final Map<String, double> _downloadProgress = {};
|
||||
final Map<String, String> _downloadError = {};
|
||||
|
||||
late StreamSubscription<TaskUpdate> _hasError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
getLibrary();
|
||||
|
||||
if (!kIsWeb) {
|
||||
DownloadService.instance.getAllDownloads().then((data) {
|
||||
for (var item in data) {
|
||||
_downloadProgress[item.taskId] = item.progress;
|
||||
|
||||
if (item.exception?.description != null) {
|
||||
_downloadError[item.taskId] = item.exception!.description;
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!kIsWeb) {
|
||||
_hasError = DownloadService.instance.updates.listen((update) async {
|
||||
if (update is TaskStatusUpdate) {
|
||||
final task =
|
||||
await DownloadService.instance.getById(update.task.taskId);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_downloadProgress[update.task.taskId] = task?.progress ?? 0;
|
||||
if (task?.exception?.description != null) {
|
||||
_downloadError[update.task.taskId] =
|
||||
task!.exception!.description;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_hasError.cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
_hasError.cancel();
|
||||
}
|
||||
|
||||
Widget _buildDownloadButton(BuildContext context, String url, String title) {
|
||||
final taskId = calculateHash(url);
|
||||
final progress = _downloadProgress[taskId];
|
||||
|
||||
return SizedBox(
|
||||
child: progress != null
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: _downloadError[taskId] == null
|
||||
? const Icon(Icons.stop_circle)
|
||||
: const Icon(Icons.delete),
|
||||
onPressed: () async {
|
||||
if (_downloadError[taskId] == null) {
|
||||
final task =
|
||||
await DownloadService.instance.getById(taskId);
|
||||
await DownloadService.instance.pauseDownload(
|
||||
task!.task as DownloadTask,
|
||||
);
|
||||
} else {
|
||||
DownloadService.instance.deleteDownload(taskId);
|
||||
setState(() {
|
||||
_downloadProgress.remove(taskId);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: () async {
|
||||
final task = DownloadTask(
|
||||
url: url,
|
||||
taskId: taskId,
|
||||
filename: "${(widget.id as Meta).name!}.mp4",
|
||||
);
|
||||
|
||||
await DownloadService.instance.startDownload(task);
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Download started'),
|
||||
action: SnackBarAction(
|
||||
label: 'View',
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/downloads');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool hasError = false;
|
||||
bool isLoading = true;
|
||||
List<StreamList>? _list;
|
||||
|
||||
final List<Error> errors = [];
|
||||
|
||||
final Map<String, StreamSource> _sources = {};
|
||||
|
||||
Future getLibrary() async {
|
||||
await BaseConnectionService.getLibraries();
|
||||
|
||||
await widget.service.getStreams(
|
||||
widget.id,
|
||||
callback: (items, error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
_list = items;
|
||||
|
||||
_list?.forEach((item) {
|
||||
if (item.streamSource != null) {
|
||||
_sources[item.streamSource!.id] = item.streamSource!;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
_list = _list ?? [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String? selectedAddonFilter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoading || _list == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return const Text("Something went wrong");
|
||||
}
|
||||
|
||||
if ((_list ?? []).isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"No stream found",
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final filteredList = (_list ?? []).where((item) {
|
||||
if (item.streamSource == null || selectedAddonFilter == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return item.streamSource!.id == selectedAddonFilter;
|
||||
}).toList();
|
||||
|
||||
return ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return SizedBox(
|
||||
height: 42,
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
),
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final value in _sources.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: ChoiceChip(
|
||||
selected: value.id == selectedAddonFilter,
|
||||
label: Text(value.title),
|
||||
onSelected: (i) {
|
||||
setState(() {
|
||||
selectedAddonFilter = i ? value.id : null;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final item = filteredList[index - 1];
|
||||
|
||||
return ListTile(
|
||||
title: Text(item.title),
|
||||
subtitle: item.description == null && item.streamSource == null
|
||||
? null
|
||||
: Text(
|
||||
"${item.description ?? ""}\n---\n${item.streamSource?.title ?? ""}"
|
||||
.trim(),
|
||||
),
|
||||
trailing: (item.source is MediaURLSource)
|
||||
? _buildDownloadButton(
|
||||
context,
|
||||
(item.source as MediaURLSource).url,
|
||||
item.title,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
if (widget.shouldPop) {
|
||||
Navigator.of(context).pop(item.source);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
PlaybackConfig config = getPlaybackConfig();
|
||||
|
||||
if (config.externalPlayer) {
|
||||
if (!kIsWeb) {
|
||||
if (item.source is URLSource || item.source is TorrentSource) {
|
||||
if (config.externalPlayer && Platform.isAndroid) {
|
||||
openVideoUrlInExternalPlayerAndroid(
|
||||
videoUrl: (item.source as URLSource).url,
|
||||
playerPackage: config.currentPlayerPackage,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final meta = (widget.id as Meta).copyWith();
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => DocViewer(
|
||||
source: item.source,
|
||||
service: widget.service,
|
||||
meta: meta,
|
||||
progress: widget.progress,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: filteredList.length + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String calculateHash(String url) {
|
||||
return url.hashCode.toString();
|
||||
}
|
||||
|
|
@ -1,461 +0,0 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:madari_client/features/connection/types/stremio.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
|
||||
class StremioCard extends StatefulWidget {
|
||||
final LibraryItem item;
|
||||
final String prefix;
|
||||
final String connectionId;
|
||||
final BaseConnectionService service;
|
||||
|
||||
const StremioCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.prefix,
|
||||
required this.connectionId,
|
||||
required this.service,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StremioCard> createState() => _StremioCardState();
|
||||
}
|
||||
|
||||
class _StremioCardState extends State<StremioCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final meta = widget.item as Meta;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
context.push(
|
||||
"/info/stremio/${widget.connectionId}/${meta.type}/${meta.id}?hero=${widget.prefix}${meta.type}${widget.item.id}",
|
||||
extra: {
|
||||
'meta': meta,
|
||||
'service': widget.service,
|
||||
},
|
||||
);
|
||||
},
|
||||
child: ((meta.currentVideo == null || meta.progress != null) ||
|
||||
(meta.forceRegular == true))
|
||||
? _buildRegular(context, meta)
|
||||
: _buildWideCard(context, meta),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildWideCard(BuildContext context, Meta meta) {
|
||||
return WideCardStremio(meta: meta);
|
||||
}
|
||||
|
||||
String? getBackgroundImage(Meta meta) {
|
||||
String? backgroundImage;
|
||||
|
||||
if (meta.currentVideo != null) {
|
||||
return meta.currentVideo?.thumbnail ?? meta.poster;
|
||||
}
|
||||
|
||||
if (meta.poster != null) {
|
||||
backgroundImage = meta.poster;
|
||||
}
|
||||
|
||||
return backgroundImage;
|
||||
}
|
||||
|
||||
_buildRegular(BuildContext context, Meta meta) {
|
||||
final backgroundImage =
|
||||
meta.poster ?? meta.logo ?? getBackgroundImage(meta);
|
||||
|
||||
return Hero(
|
||||
tag: "${widget.prefix}${meta.type}${widget.item.id}",
|
||||
child: (backgroundImage == null)
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Colors.grey,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
meta.name ?? "No title",
|
||||
style:
|
||||
Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Colors.black54,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(backgroundImage)}@webp",
|
||||
imageRenderMethodForWeb:
|
||||
ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: meta.imdbRating != ""
|
||||
? Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.star,
|
||||
color: Colors.amber,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
meta.imdbRating,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (meta.progress != null)
|
||||
const Positioned.fill(
|
||||
child: IconButton(
|
||||
onPressed: null,
|
||||
icon: Icon(
|
||||
Icons.play_arrow,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (meta.progress != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: meta.progress! / 100,
|
||||
minHeight: 5,
|
||||
),
|
||||
),
|
||||
if (meta.currentVideo != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.grey,
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.bottomLeft,
|
||||
end: Alignment.topRight,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4, horizontal: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
meta.name ?? "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
"S${meta.currentVideo?.season} E${meta.currentVideo?.episode}",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WideCardStremio extends StatefulWidget {
|
||||
final Meta meta;
|
||||
final Video? video;
|
||||
|
||||
const WideCardStremio({
|
||||
super.key,
|
||||
required this.meta,
|
||||
this.video,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WideCardStremio> createState() => _WideCardStremioState();
|
||||
}
|
||||
|
||||
class _WideCardStremioState extends State<WideCardStremio> {
|
||||
bool hasErrorWhileLoading = false;
|
||||
|
||||
bool get isInFuture {
|
||||
final video = widget.video ?? widget.meta.currentVideo;
|
||||
return video != null &&
|
||||
video.firstAired != null &&
|
||||
video.firstAired!.isAfter(DateTime.now());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.meta.background == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
final video = widget.video ?? widget.meta.currentVideo;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(
|
||||
hasErrorWhileLoading
|
||||
? widget.meta.background!
|
||||
: (widget.meta.currentVideo?.thumbnail ??
|
||||
widget.meta.background!),
|
||||
)}@webp",
|
||||
errorListener: (error) {
|
||||
setState(() {
|
||||
hasErrorWhileLoading = true;
|
||||
});
|
||||
},
|
||||
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (isInFuture)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black54,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.bottomLeft,
|
||||
end: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"${widget.meta.name}",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
"S${video?.season} E${video?.episode}",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${video?.name ?? video?.title}".trim(),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isInFuture)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
getRelativeDate(video!.firstAired!),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
if (isInFuture)
|
||||
const Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.calendar_month,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
child: Center(
|
||||
child: IconButton.filled(
|
||||
onPressed: null,
|
||||
icon: Icon(
|
||||
Icons.play_arrow,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
widget.meta.imdbRating != "" && widget.video == null
|
||||
? Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.star,
|
||||
color: Colors.amber,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.meta.imdbRating,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String getRelativeDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final tomorrow = DateTime(now.year, now.month, now.day + 1);
|
||||
|
||||
final difference = date.difference(today).inDays;
|
||||
|
||||
if (date.isAtSameMomentAs(today)) {
|
||||
return "It's today!";
|
||||
} else if (date.isAtSameMomentAs(tomorrow)) {
|
||||
return "Coming up tomorrow!";
|
||||
} else if (difference > 1 && difference < 7) {
|
||||
return "Coming up in $difference days";
|
||||
} else if (difference >= 7 && difference < 14) {
|
||||
return "Coming up next ${DateFormat('EEEE').format(date)}";
|
||||
} else {
|
||||
return "On ${DateFormat('MM/dd/yyyy').format(date)}";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class StremioCreateConnection extends StatelessWidget {
|
||||
const StremioCreateConnection({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/connection/services/stremio_service.dart';
|
||||
|
||||
import '../../service/base_connection_service.dart';
|
||||
|
||||
typedef FilterCallback = void Function(List<ConnectionFilterItem> item);
|
||||
|
||||
class InlineFilters extends StatefulWidget {
|
||||
final List<ConnectionFilter<dynamic>> filters;
|
||||
final FilterCallback filterCallback;
|
||||
|
||||
const InlineFilters({
|
||||
super.key,
|
||||
required this.filters,
|
||||
required this.filterCallback,
|
||||
});
|
||||
|
||||
@override
|
||||
State<InlineFilters> createState() => _InlineFiltersState();
|
||||
}
|
||||
|
||||
class _InlineFiltersState extends State<InlineFilters> {
|
||||
final Map<String, dynamic> _selectedValues = {};
|
||||
|
||||
List<ConnectionFilterItem> generateFilterItem() {
|
||||
final List<ConnectionFilterItem> items = [];
|
||||
|
||||
for (final item in _selectedValues.keys) {
|
||||
items.add(
|
||||
ConnectionFilterItem(title: item, value: _selectedValues[item]!),
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onChange() {
|
||||
widget.filterCallback(generateFilterItem());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: widget.filters
|
||||
.where((filter) => filter.type == ConnectionFilterType.options)
|
||||
.map((filter) {
|
||||
final isSelected = _selectedValues.containsKey(filter.title);
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: InputChip(
|
||||
label: Text(
|
||||
(isSelected ? _selectedValues[filter.title] : filter.title)
|
||||
.toString()
|
||||
.capitalize(),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: theme.textTheme.bodyMedium?.color,
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
onPressed: () {
|
||||
if (isSelected) {
|
||||
setState(() {
|
||||
_selectedValues.remove(filter.title);
|
||||
});
|
||||
|
||||
onChange();
|
||||
} else {
|
||||
_showOptionsDialog(filter);
|
||||
}
|
||||
},
|
||||
deleteIcon: isSelected
|
||||
? const Icon(
|
||||
Icons.close,
|
||||
)
|
||||
: null,
|
||||
onDeleted: isSelected
|
||||
? () {
|
||||
setState(() {
|
||||
_selectedValues.remove(filter.title);
|
||||
|
||||
onChange();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showOptionsDialog(ConnectionFilter<dynamic> filter) async {
|
||||
final selectedValue = await showDialog<dynamic>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return SimpleDialog(
|
||||
title: Text(filter.title),
|
||||
children: (filter.values ?? []).map((value) {
|
||||
return SimpleDialogOption(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, value);
|
||||
},
|
||||
child: Text(value.toString()),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (selectedValue != null) {
|
||||
setState(() {
|
||||
_selectedValues[filter.title] = selectedValue;
|
||||
});
|
||||
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,520 +0,0 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/connections/widget/base/render_stream_list.dart';
|
||||
import 'package:madari_client/features/connections/widget/stremio/stremio_season_selector.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../types/stremio/stremio_base.types.dart';
|
||||
|
||||
class StremioItemViewer extends StatefulWidget {
|
||||
final Meta? meta;
|
||||
final Meta? original;
|
||||
final String? hero;
|
||||
final BaseConnectionService? service;
|
||||
final num? progress;
|
||||
|
||||
const StremioItemViewer({
|
||||
super.key,
|
||||
this.meta,
|
||||
this.original,
|
||||
this.hero,
|
||||
this.service,
|
||||
this.progress,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StremioItemViewer> createState() => _StremioItemViewerState();
|
||||
}
|
||||
|
||||
class _StremioItemViewerState extends State<StremioItemViewer> {
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
bool get _isLoading {
|
||||
return widget.original == null;
|
||||
}
|
||||
|
||||
Meta? _item;
|
||||
|
||||
Meta? get item {
|
||||
return _item ?? widget.meta;
|
||||
}
|
||||
|
||||
void _onPlayPressed(BuildContext context) {
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
title: const Text("Streams"),
|
||||
),
|
||||
body: widget.service == null
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: RenderStreamList(
|
||||
service: widget.service!,
|
||||
id: widget.meta as LibraryItem,
|
||||
shouldPop: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isWideScreen = screenWidth > 900;
|
||||
final contentWidth = isWideScreen ? 900.0 : screenWidth;
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Text("Failed $_errorMessage");
|
||||
}
|
||||
|
||||
if (item == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: isWideScreen ? 600 : 500,
|
||||
pinned: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(40),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
color: Colors.black,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal:
|
||||
isWideScreen ? (screenWidth - contentWidth) / 2 : 16,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
(item!.name ?? "No name"),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton.icon(
|
||||
icon: _isLoading
|
||||
? Container(
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
child: const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 24,
|
||||
color: Colors.black87,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () {
|
||||
if (item!.type == "series" && _isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
_onPlayPressed(context);
|
||||
},
|
||||
label: Text(
|
||||
widget.progress != null && widget.progress != 0
|
||||
? "Resume"
|
||||
: "Play",
|
||||
style: Theme.of(context)
|
||||
.primaryTextTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (item!.background != null)
|
||||
Image.network(
|
||||
item!.background!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
if (item!.poster == null) {
|
||||
return Container();
|
||||
}
|
||||
return Image.network(item!.poster!,
|
||||
fit: BoxFit.cover);
|
||||
},
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 86,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isWideScreen
|
||||
? (screenWidth - contentWidth) / 2
|
||||
: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Hero(
|
||||
tag: "${widget.hero}",
|
||||
child: Container(
|
||||
width: 150,
|
||||
height: 225,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
image: item!.poster == null
|
||||
? null
|
||||
: DecorationImage(
|
||||
image: NetworkImage(item!.poster!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (item!.year != null)
|
||||
Chip(
|
||||
label: Text("${item!.year ?? ""}"),
|
||||
backgroundColor: Colors.white24,
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (item!.imdbRating != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.star,
|
||||
color: Colors.amber,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item!.imdbRating!,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.original != null &&
|
||||
widget.original?.type == "series" &&
|
||||
widget.original?.videos?.isNotEmpty == true)
|
||||
StremioItemSeasonSelector(
|
||||
meta: (item as Meta).copyWith(
|
||||
selectedVideoIndex: widget.meta?.selectedVideoIndex,
|
||||
),
|
||||
service: widget.service,
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal:
|
||||
isWideScreen ? (screenWidth - contentWidth) / 2 : 16,
|
||||
vertical: 16,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
if (widget.original != null)
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Text(
|
||||
'Description',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (item!.description != null) const SizedBox(height: 8),
|
||||
if (item!.description != null)
|
||||
Text(
|
||||
item!.description!,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Additional Details
|
||||
_buildDetailSection(context, 'Additional Information', [
|
||||
if (item!.genre != null)
|
||||
_buildDetailRow('Genres', item!.genre!.join(', ')),
|
||||
if (item!.country != null)
|
||||
_buildDetailRow('Country', item!.country!),
|
||||
if (item!.runtime != null)
|
||||
_buildDetailRow('Runtime', item!.runtime!),
|
||||
if (item!.language != null)
|
||||
_buildDetailRow('Language', item!.language!),
|
||||
]),
|
||||
|
||||
// Cast
|
||||
if (item!.creditsCast != null &&
|
||||
item!.creditsCast!.isNotEmpty)
|
||||
_buildCastSection(context, item!.creditsCast!),
|
||||
|
||||
// Cast
|
||||
if (item!.creditsCrew != null &&
|
||||
item!.creditsCrew!.isNotEmpty)
|
||||
_buildCastSection(
|
||||
context,
|
||||
title: "Crew",
|
||||
item!.creditsCrew!.map((item) {
|
||||
return CreditsCast(
|
||||
character: item.department,
|
||||
name: item.name,
|
||||
profilePath: item.profilePath,
|
||||
id: item.id,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
// Trailers
|
||||
if (item!.trailerStreams != null &&
|
||||
item!.trailerStreams!.isNotEmpty)
|
||||
_buildTrailersSection(context, item!.trailerStreams!),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailSection(
|
||||
BuildContext context, String title, List<Widget> details) {
|
||||
if (details.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...details,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCastSection(
|
||||
BuildContext context,
|
||||
List<CreditsCast> cast, {
|
||||
String title = "Cast",
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: cast.length,
|
||||
itemBuilder: (context, index) {
|
||||
final actor = cast[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundImage: actor.profilePath != null
|
||||
? CachedNetworkImageProvider(
|
||||
actor.profilePath!.startsWith("/")
|
||||
? "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://image.tmdb.org/t/p/original/${actor.profilePath}")}@webp"
|
||||
: actor.profilePath!,
|
||||
imageRenderMethodForWeb:
|
||||
ImageRenderMethodForWeb.HttpGet,
|
||||
)
|
||||
: null,
|
||||
child: actor.profilePath == null
|
||||
? Icon(
|
||||
Icons.person,
|
||||
size: 50,
|
||||
color: Colors.grey[300],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
actor.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
actor.character,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrailersSection(
|
||||
BuildContext context, List<TrailerStream> trailers) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Trailers',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: trailers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final trailer = trailers[index];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
"https://www.youtube-nocookie.com/embed/${trailer.ytId}?autoplay=1&color=red&disablekb=1&enablejsapi=1&fs=1",
|
||||
);
|
||||
|
||||
launchUrl(
|
||||
url,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Container(
|
||||
width: 160,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.black26,
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://i.ytimg.com/vi/${trailer.ytId}/mqdefault.jpg")}@webp",
|
||||
imageRenderMethodForWeb:
|
||||
ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
trailer.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,476 +0,0 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/connections/widget/base/render_stream_list.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../types/stremio/stremio_base.types.dart';
|
||||
|
||||
class StremioItemViewerTV extends StatefulWidget {
|
||||
final Meta? meta;
|
||||
final Meta? original;
|
||||
final String? hero;
|
||||
final BaseConnectionService? service;
|
||||
final String library;
|
||||
|
||||
const StremioItemViewerTV({
|
||||
super.key,
|
||||
this.meta,
|
||||
this.original,
|
||||
this.hero,
|
||||
this.service,
|
||||
required this.library,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StremioItemViewerTV> createState() => _StremioItemViewerTVState();
|
||||
}
|
||||
|
||||
class _StremioItemViewerTVState extends State<StremioItemViewerTV> {
|
||||
String? _errorMessage;
|
||||
final FocusNode _playButtonFocusNode = FocusNode();
|
||||
final FocusNode _trailersFocusNode = FocusNode();
|
||||
bool _showTrailers = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Set initial focus to the Play button
|
||||
_playButtonFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playButtonFocusNode.dispose();
|
||||
_trailersFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isLoading {
|
||||
return widget.original == null;
|
||||
}
|
||||
|
||||
Meta? _item;
|
||||
|
||||
Meta? get item {
|
||||
return _item ?? widget.meta;
|
||||
}
|
||||
|
||||
void _onPlayPressed(BuildContext context) {
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
title: const Text("Streams"),
|
||||
),
|
||||
body: widget.service == null
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: RenderStreamList(
|
||||
service: widget.service!,
|
||||
id: widget.meta as LibraryItem,
|
||||
shouldPop: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Text("Failed $_errorMessage"),
|
||||
);
|
||||
}
|
||||
|
||||
if (item == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Static Background
|
||||
if (item!.background != null)
|
||||
Positioned.fill(
|
||||
child: Image.network(
|
||||
item!.background!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
if (item!.poster == null) {
|
||||
return Container();
|
||||
}
|
||||
return Image.network(item!.poster!, fit: BoxFit.cover);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Gradient Overlay
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Content
|
||||
SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Title
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
item!.name ?? "No Title",
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// Poster and Details Section
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 900,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Poster
|
||||
Hero(
|
||||
tag: "${widget.hero}",
|
||||
child: Container(
|
||||
width: 150,
|
||||
height: 225,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
image: item!.poster == null
|
||||
? null
|
||||
: DecorationImage(
|
||||
image:
|
||||
NetworkImage(item!.poster!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
Colors.black.withOpacity(0.3),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Details
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Year and Rating
|
||||
Row(
|
||||
children: [
|
||||
if (item!.year != null)
|
||||
Chip(
|
||||
label:
|
||||
Text("${item!.year ?? ""}"),
|
||||
backgroundColor: Colors.white24,
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (item!.imdbRating != "")
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.star,
|
||||
color: Colors.amber,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item!.imdbRating,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color:
|
||||
Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Description
|
||||
Text(
|
||||
'Description',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge,
|
||||
),
|
||||
if (item!.description != null)
|
||||
const SizedBox(height: 8),
|
||||
if (item!.description != null)
|
||||
Text(
|
||||
item!.description!,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Additional Details
|
||||
_buildDetailSection(
|
||||
context, 'Additional Information', [
|
||||
if (item!.genre != null)
|
||||
_buildDetailRow('Genres',
|
||||
item!.genre!.join(', ')),
|
||||
if (item!.country != null)
|
||||
_buildDetailRow(
|
||||
'Country', item!.country!),
|
||||
if (item!.runtime != null)
|
||||
_buildDetailRow(
|
||||
'Runtime', item!.runtime!),
|
||||
if (item!.language != null)
|
||||
_buildDetailRow(
|
||||
'Language', item!.language!),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Play Button
|
||||
Focus(
|
||||
focusNode: _playButtonFocusNode,
|
||||
onKey: (node, event) {
|
||||
if (event is RawKeyDownEvent) {
|
||||
if (event.logicalKey ==
|
||||
LogicalKeyboardKey.arrowDown) {
|
||||
// Show Trailers
|
||||
setState(() {
|
||||
_showTrailers = true;
|
||||
});
|
||||
FocusScope.of(context)
|
||||
.requestFocus(_trailersFocusNode);
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey ==
|
||||
LogicalKeyboardKey.enter) {
|
||||
// Play the item
|
||||
_onPlayPressed(context);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: ElevatedButton.icon(
|
||||
icon: _isLoading
|
||||
? Container(
|
||||
margin:
|
||||
const EdgeInsets.only(right: 6),
|
||||
child: const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 24,
|
||||
color: Colors.black87,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () {
|
||||
if (item!.type == "series" && _isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
_onPlayPressed(context);
|
||||
},
|
||||
label: Text(
|
||||
"Play",
|
||||
style: Theme.of(context)
|
||||
.primaryTextTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showTrailers &&
|
||||
item!.trailerStreams != null &&
|
||||
item!.trailerStreams!.isNotEmpty)
|
||||
Focus(
|
||||
focusNode: _trailersFocusNode,
|
||||
onKey: (node, event) {
|
||||
if (event is RawKeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
// Hide Trailers and move focus back to Play Button
|
||||
setState(() {
|
||||
_showTrailers = false;
|
||||
});
|
||||
FocusScope.of(context)
|
||||
.requestFocus(_playButtonFocusNode);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child:
|
||||
_buildTrailersSection(context, item!.trailerStreams!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailSection(
|
||||
BuildContext context, String title, List<Widget> details) {
|
||||
if (details.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...details,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrailersSection(
|
||||
BuildContext context, List<TrailerStream> trailers) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Trailers',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: trailers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final trailer = trailers[index];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
"https://www.youtube-nocookie.com/embed/${trailer.ytId}?autoplay=1&color=red&disablekb=1&enablejsapi=1&fs=1",
|
||||
);
|
||||
|
||||
launchUrl(
|
||||
url,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Container(
|
||||
width: 160,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.black26,
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://i.ytimg.com/vi/${trailer.ytId}/mqdefault.jpg")}@webp",
|
||||
imageRenderMethodForWeb:
|
||||
ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
trailer.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../service/base_connection_service.dart';
|
||||
|
||||
class StremioListItem extends StatelessWidget {
|
||||
final LibraryItem item;
|
||||
|
||||
const StremioListItem({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ListTile();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,473 +0,0 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:madari_client/features/connection/types/stremio.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/connections/widget/base/render_stream_list.dart';
|
||||
import 'package:madari_client/features/trakt/service/trakt.service.dart';
|
||||
import 'package:madari_client/utils/common.dart';
|
||||
|
||||
import '../../../doc_viewer/types/doc_source.dart';
|
||||
import '../../../watch_history/service/base_watch_history.dart';
|
||||
import '../../../watch_history/service/zeee_watch_history.dart';
|
||||
|
||||
class StremioItemSeasonSelector extends StatefulWidget {
|
||||
final Meta meta;
|
||||
final int? season;
|
||||
final BaseConnectionService? service;
|
||||
final bool shouldPop;
|
||||
|
||||
const StremioItemSeasonSelector({
|
||||
super.key,
|
||||
required this.meta,
|
||||
this.season,
|
||||
required this.service,
|
||||
this.shouldPop = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StremioItemSeasonSelector> createState() =>
|
||||
_StremioItemSeasonSelectorState();
|
||||
}
|
||||
|
||||
class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
||||
with SingleTickerProviderStateMixin {
|
||||
int? selectedSeason;
|
||||
late TabController? _tabController;
|
||||
late final Map<int, List<Video>> seasonMap;
|
||||
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
|
||||
|
||||
late Meta meta = widget.meta;
|
||||
|
||||
final Map<String, double> _progress = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
seasonMap = _organizeEpisodes();
|
||||
|
||||
if (seasonMap.keys.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final index = getSelectedSeason();
|
||||
|
||||
_tabController = TabController(
|
||||
length: seasonMap.keys.length,
|
||||
vsync: this,
|
||||
initialIndex: index.clamp(
|
||||
0,
|
||||
seasonMap.keys.isNotEmpty ? seasonMap.keys.length - 1 : 0,
|
||||
),
|
||||
);
|
||||
|
||||
// This is for rendering the component again for the selection of another tab
|
||||
_tabController!.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
getWatchHistory();
|
||||
}
|
||||
|
||||
int getSelectedSeason() {
|
||||
return widget.meta.currentVideo?.season ??
|
||||
widget.meta.videos?.lastWhereOrNull((item) {
|
||||
return item.progress != null;
|
||||
})?.season ??
|
||||
widget.season ??
|
||||
0;
|
||||
}
|
||||
|
||||
getWatchHistory() async {
|
||||
final traktService = TraktService.instance;
|
||||
|
||||
try {
|
||||
if (TraktService.isEnabled()) {
|
||||
final result = await traktService!.getProgress(
|
||||
widget.meta,
|
||||
bypassCache: false,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
meta = result;
|
||||
});
|
||||
|
||||
final index = getSelectedSeason();
|
||||
_tabController?.animateTo(index);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (e, stack) {
|
||||
print(e);
|
||||
print(stack);
|
||||
print("Unable to get trakt progress");
|
||||
}
|
||||
|
||||
final docs = await zeeeWatchHistory!.getItemWatchHistory(
|
||||
ids: widget.meta.videos!.map((item) {
|
||||
return WatchHistoryGetRequest(
|
||||
id: item.id,
|
||||
episode: item.episode.toString(),
|
||||
season: item.season.toString(),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
for (var item in docs) {
|
||||
_progress[item.id] = item.progress.toDouble();
|
||||
}
|
||||
|
||||
final index = getSelectedSeason();
|
||||
|
||||
_tabController?.animateTo(index);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<int, List<Video>> _organizeEpisodes() {
|
||||
final episodes = meta.videos ?? [];
|
||||
return groupBy(episodes, (Video video) => video.season);
|
||||
}
|
||||
|
||||
void openEpisode({
|
||||
required int index,
|
||||
}) async {
|
||||
if (widget.service == null) {
|
||||
return;
|
||||
}
|
||||
final onClose = showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final meta = this.meta.copyWith(
|
||||
selectedVideoIndex: index,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
"Streams for S${meta.currentVideo?.season} E${meta.currentVideo?.episode}",
|
||||
),
|
||||
),
|
||||
body: RenderStreamList(
|
||||
service: widget.service!,
|
||||
id: meta,
|
||||
shouldPop: widget.shouldPop,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (widget.shouldPop) {
|
||||
final val = await onClose;
|
||||
|
||||
if (val is MediaURLSource && context.mounted && mounted) {
|
||||
Navigator.pop(
|
||||
context,
|
||||
val,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onClose.then((data) {
|
||||
getWatchHistory();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final seasons = seasonMap.keys.toList()..sort();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isWideScreen = screenWidth > 900;
|
||||
final contentWidth = isWideScreen ? 900.0 : screenWidth;
|
||||
|
||||
if (_tabController == null) {
|
||||
return const SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 0,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isWideScreen ? (screenWidth - contentWidth) / 2 : 8,
|
||||
),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
label: const Text("Random Episode"),
|
||||
onPressed: () {
|
||||
Random random = Random();
|
||||
int randomIndex = random.nextInt(
|
||||
widget.meta.videos!.length,
|
||||
);
|
||||
|
||||
openEpisode(index: randomIndex);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TabBar(
|
||||
tabAlignment: TabAlignment.start,
|
||||
dividerColor: Colors.transparent,
|
||||
controller: _tabController,
|
||||
isScrollable: true,
|
||||
splashBorderRadius: BorderRadius.circular(8),
|
||||
padding: const EdgeInsets.all(4),
|
||||
tabs: seasons.map((season) {
|
||||
return Tab(
|
||||
text: season == 0 ? "Specials" : 'Season $season',
|
||||
height: 40,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isWideScreen ? (screenWidth - contentWidth) / 2 : 8,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final currentSeason = seasons[_tabController!.index];
|
||||
final episodes = seasonMap[currentSeason]!;
|
||||
final episode = episodes[index];
|
||||
|
||||
final videoIndex = meta.videos?.indexOf(episode);
|
||||
|
||||
final progress = ((!TraktService.isEnabled()
|
||||
? (_progress[episode.id] ?? 0) / 100
|
||||
: videoIndex != -1
|
||||
? (meta.videos![videoIndex!].progress)
|
||||
: 0.toDouble()) ??
|
||||
0) /
|
||||
100;
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () async {
|
||||
if (videoIndex != null) {
|
||||
openEpisode(
|
||||
index: videoIndex,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0,
|
||||
top: 8.0,
|
||||
bottom: 8.0,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
child: episode.thumbnail != null &&
|
||||
episode.thumbnail!.isNotEmpty
|
||||
? Image.network(
|
||||
episode.thumbnail!,
|
||||
width: 140,
|
||||
height: 90,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 140,
|
||||
height: 90,
|
||||
color: colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.movie,
|
||||
color:
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: 140,
|
||||
height: 90,
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.movie,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.play_arrow,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: progress,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
if (progress > .9)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.teal,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 4.0,
|
||||
bottom: 2.0,
|
||||
left: 4.0,
|
||||
top: 2.0,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"Watched",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8.0,
|
||||
bottom: 8.0,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${index + 1}. ${episode.name ?? 'Episode ${episode.episode}'}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (episode.released != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
intl.DateFormat('MMMM dd yyyy')
|
||||
.format(episode.released!),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurface
|
||||
.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (episode.overview != null) ...[
|
||||
Text(
|
||||
episode.overview!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: colorScheme.onSurface
|
||||
.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount:
|
||||
seasonMap[seasons[_tabController!.index]]?.length ?? 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<T, List<E>> groupBy<E, T>(Iterable<E> items, T Function(E) key) {
|
||||
final map = <T, List<E>>{};
|
||||
|
||||
for (final item in items) {
|
||||
final keyValue = key(item);
|
||||
if (!map.containsKey(keyValue)) {
|
||||
map[keyValue] = [];
|
||||
}
|
||||
map[keyValue]!.add(item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/pdf_viewer.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/photo_viewer.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
|
||||
import 'iframe.dart';
|
||||
|
||||
class DocViewer extends StatefulWidget {
|
||||
final DocSource source;
|
||||
|
||||
final String? library;
|
||||
|
||||
final LibraryItem? meta;
|
||||
final String? season;
|
||||
final BaseConnectionService? service;
|
||||
|
||||
final double? progress;
|
||||
|
||||
const DocViewer({
|
||||
super.key,
|
||||
required this.source,
|
||||
this.service,
|
||||
this.library,
|
||||
this.meta,
|
||||
this.season,
|
||||
this.progress,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DocViewer> createState() => _DocViewerState();
|
||||
}
|
||||
|
||||
class _DocViewerState extends State<DocViewer> {
|
||||
bool isReady = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
widget.source.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.source.init().then((_) {
|
||||
setState(() {
|
||||
isReady = true;
|
||||
});
|
||||
}).catchError((err) {
|
||||
setState(() {
|
||||
if (mounted) {
|
||||
_errorMessage = err.toString();
|
||||
}
|
||||
});
|
||||
widget.source.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!isReady) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
title: Text(widget.source.title),
|
||||
),
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Text("Error $_errorMessage");
|
||||
}
|
||||
|
||||
if (widget.source is IframeSource) {
|
||||
return IframeViewer(
|
||||
source: widget.source as IframeSource,
|
||||
);
|
||||
}
|
||||
|
||||
switch (widget.source.getType()) {
|
||||
case DocType.pdf:
|
||||
return PDFViewerContainer(source: widget.source);
|
||||
case DocType.photo:
|
||||
return PhotoViewer(source: widget.source);
|
||||
case DocType.video:
|
||||
return VideoViewer(
|
||||
source: widget.source,
|
||||
meta: widget.meta,
|
||||
service: widget.service,
|
||||
currentSeason: widget.season,
|
||||
library: widget.library,
|
||||
);
|
||||
default:
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
appBar: AppBar(
|
||||
title: Text(widget.source.title),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.broken_image,
|
||||
size: 42,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Text(
|
||||
"Unsupported file ${widget.source.title}",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
|
||||
class AdblockList {
|
||||
static String str = "";
|
||||
}
|
||||
|
||||
class IframeViewer extends StatefulWidget {
|
||||
final IframeSource source;
|
||||
const IframeViewer({
|
||||
super.key,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
@override
|
||||
State<IframeViewer> createState() => _IframeViewerState();
|
||||
}
|
||||
|
||||
class _IframeViewerState extends State<IframeViewer> {
|
||||
final List<ContentBlocker> contentBlockers = [];
|
||||
bool _isFullScreen = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final url = AdblockList.str
|
||||
.split("\n")
|
||||
.where((item) => item.trim() != "")
|
||||
.map((item) {
|
||||
return ".*.$item/.*";
|
||||
}).toList();
|
||||
|
||||
for (final adUrlFilter in url) {
|
||||
contentBlockers.add(
|
||||
ContentBlocker(
|
||||
trigger: ContentBlockerTrigger(
|
||||
urlFilter: adUrlFilter,
|
||||
),
|
||||
action: ContentBlockerAction(
|
||||
type: ContentBlockerActionType.BLOCK,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
contentBlockers.add(
|
||||
ContentBlocker(
|
||||
trigger: ContentBlockerTrigger(
|
||||
urlFilter: ".*",
|
||||
),
|
||||
action: ContentBlockerAction(
|
||||
type: ContentBlockerActionType.CSS_DISPLAY_NONE,
|
||||
selector: """
|
||||
.banner, .banners, .ads, .ad, .advert, .advertisement,
|
||||
[class*="ad-"], [class*="Ad"], [class*="advertisement"],
|
||||
[id*="google_ads"], [id*="ad-"],
|
||||
iframe[src*="ads"], iframe[src*="doubleclick"],
|
||||
div[aria-label*="advertisement"],
|
||||
.social-share, .newsletter-signup,
|
||||
.popup, .modal-overlay, .cookie-notice,
|
||||
[class*="cookie-banner"], [id*="cookie-consent"]
|
||||
""",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
color: Colors.black,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isFullScreen = !_isFullScreen;
|
||||
});
|
||||
if (_isFullScreen) {
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: InAppWebView(
|
||||
initialSettings: InAppWebViewSettings(
|
||||
contentBlockers: contentBlockers,
|
||||
useShouldOverrideUrlLoading: true,
|
||||
iframeAllow:
|
||||
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
|
||||
iframeCsp: "",
|
||||
iframeReferrerPolicy: ReferrerPolicy.ORIGIN,
|
||||
iframeAllowFullscreen: true,
|
||||
),
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri(
|
||||
widget.source.url,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../../../engine/engine.dart';
|
||||
import 'magic_page_selector_bottom_sheet.dart';
|
||||
|
||||
class MagicBottomSheet extends StatefulWidget {
|
||||
final PdfViewerController controller;
|
||||
const MagicBottomSheet({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MagicBottomSheet> createState() => _MagicBottomSheetState();
|
||||
}
|
||||
|
||||
class _MagicBottomSheetState extends State<MagicBottomSheet> {
|
||||
final pb = AppEngine.engine.pb;
|
||||
|
||||
late Future<ResultList<RecordModel>> item;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
item = pb.collection("ai_action").getList(perPage: 100);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: item,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Scaffold(
|
||||
body: Text("Error: ${snapshot.error}"),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {},
|
||||
),
|
||||
title: const Text("AI Actions"),
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: snapshot.data!.items.length,
|
||||
itemBuilder: (ctx, index) {
|
||||
final item = snapshot.data!.items[index];
|
||||
final description = item.getStringValue("description");
|
||||
|
||||
return ListTile(
|
||||
onTap: () async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return MagicPageSelectorBottomSheet(
|
||||
item: item,
|
||||
controller: widget.controller,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (context.mounted && mounted) {
|
||||
Navigator.pop(context, [item, result]);
|
||||
}
|
||||
},
|
||||
leading: const Icon(Icons.question_answer_outlined),
|
||||
title: Text(
|
||||
snapshot.data!.items[index].getStringValue("title"),
|
||||
),
|
||||
subtitle: description != ""
|
||||
? Text(
|
||||
description,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
class MagicPageSelectorBottomSheet extends StatelessWidget {
|
||||
final RecordModel item;
|
||||
final PdfViewerController controller;
|
||||
|
||||
const MagicPageSelectorBottomSheet({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Select 📃"),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_present),
|
||||
title: const Text('Current Page'),
|
||||
subtitle: Text('Page ${controller.pageNumber}'),
|
||||
onTap: () {
|
||||
Navigator.pop(context, [controller.pageNumber!]);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_frames),
|
||||
title: const Text('Page Range'),
|
||||
subtitle: const Text('Select a range of pages'),
|
||||
onTap: () async {
|
||||
final RangeValues? result = await showDialog<RangeValues>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return PageRangeDialog(
|
||||
maxPages: controller.pageCount,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
final List<int> pages = List.generate(
|
||||
(result.end - result.start + 1).toInt(),
|
||||
(index) => index + result.start.toInt(),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context, pages);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.all_inclusive),
|
||||
title: const Text('All Pages'),
|
||||
subtitle: Text('Total ${controller.pageCount} pages'),
|
||||
onTap: () {
|
||||
final List<int> allPages = List.generate(
|
||||
controller.pageCount,
|
||||
(index) => index + 1,
|
||||
);
|
||||
Navigator.pop(context, allPages);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Additional dialog for page range selection
|
||||
class PageRangeDialog extends StatefulWidget {
|
||||
final int maxPages;
|
||||
|
||||
const PageRangeDialog({
|
||||
super.key,
|
||||
required this.maxPages,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PageRangeDialog> createState() => _PageRangeDialogState();
|
||||
}
|
||||
|
||||
class _PageRangeDialogState extends State<PageRangeDialog> {
|
||||
late RangeValues _currentRangeValues;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentRangeValues = RangeValues(1, widget.maxPages.toDouble());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Select Page Range'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RangeSlider(
|
||||
values: _currentRangeValues,
|
||||
min: 1,
|
||||
max: widget.maxPages.toDouble(),
|
||||
divisions: widget.maxPages - 1,
|
||||
labels: RangeLabels(
|
||||
_currentRangeValues.start.round().toString(),
|
||||
_currentRangeValues.end.round().toString(),
|
||||
),
|
||||
onChanged: (RangeValues values) {
|
||||
setState(() {
|
||||
_currentRangeValues = values;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text(
|
||||
'Pages ${_currentRangeValues.start.round()} to ${_currentRangeValues.end.round()}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _currentRangeValues),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:madari_client/engine/engine.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MagicShowMarkdown extends StatefulWidget {
|
||||
final RecordModel record;
|
||||
final List<int> pages;
|
||||
final PdfViewerController controller;
|
||||
final String fileName;
|
||||
|
||||
const MagicShowMarkdown({
|
||||
super.key,
|
||||
required this.record,
|
||||
required this.pages,
|
||||
required this.controller,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MagicShowMarkdown> createState() => _MagicShowMarkdownState();
|
||||
}
|
||||
|
||||
class _MagicShowMarkdownState extends State<MagicShowMarkdown> {
|
||||
final List<String> markdownChunks = [];
|
||||
bool isLoading = true;
|
||||
String? error;
|
||||
bool isStreaming = false;
|
||||
final Set<int> selectedChunks = {};
|
||||
bool isSelectionMode = false;
|
||||
final RegExp mermaidRegex = RegExp(r'```mermaid\n([\s\S]*?)```');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_extractAndStream();
|
||||
}
|
||||
|
||||
Future<String> _extractPdfText() async {
|
||||
return "";
|
||||
}
|
||||
|
||||
void _extractAndStream() async {}
|
||||
|
||||
void _retryStreaming() {
|
||||
setState(() {
|
||||
error = null;
|
||||
isLoading = true;
|
||||
markdownChunks.clear();
|
||||
});
|
||||
_extractAndStream();
|
||||
}
|
||||
|
||||
void _handleMarkdownTap(String text, String? href) {
|
||||
if (href != null) {
|
||||
launchUrl(Uri.parse(href));
|
||||
}
|
||||
}
|
||||
|
||||
void _copyText(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Copied to clipboard')),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteChunk(int index) {
|
||||
setState(() {
|
||||
markdownChunks.removeAt(index);
|
||||
selectedChunks.remove(index);
|
||||
});
|
||||
}
|
||||
|
||||
void _saveSelectedChunks() async {
|
||||
try {
|
||||
final selectedTexts = selectedChunks.toList()..sort();
|
||||
|
||||
final textItem =
|
||||
selectedTexts.map((index) => markdownChunks[index]).toList();
|
||||
|
||||
await AppEngine.engine.pb.collection('saved_responses').create(
|
||||
body: {
|
||||
'content': textItem.join("\n\n"),
|
||||
'file_name': widget.fileName,
|
||||
'user': AppEngine.engine.pb.authStore.record!.id,
|
||||
},
|
||||
);
|
||||
|
||||
if (context.mounted && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Saved successfully')),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
isSelectionMode = false;
|
||||
selectedChunks.clear();
|
||||
});
|
||||
} catch (e) {
|
||||
if (context.mounted && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Error saving: ${(e as ClientException).response["message"]}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMarkdownContent(String chunk) {
|
||||
final mermaidMatches = mermaidRegex.allMatches(chunk);
|
||||
|
||||
if (mermaidMatches.isNotEmpty) {
|
||||
List<Widget> contentWidgets = [];
|
||||
int lastEnd = 0;
|
||||
|
||||
// Process each match and the text between matches
|
||||
for (var match in mermaidMatches) {
|
||||
// Add markdown content before the diagram if exists
|
||||
if (match.start > lastEnd) {
|
||||
final beforeText = chunk.substring(lastEnd, match.start);
|
||||
if (beforeText.trim().isNotEmpty) {
|
||||
contentWidgets.add(
|
||||
MarkdownBody(
|
||||
data: beforeText,
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) =>
|
||||
_handleMarkdownTap(text, href),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lastEnd = match.end;
|
||||
}
|
||||
|
||||
// Add any remaining markdown content after the last diagram
|
||||
if (lastEnd < chunk.length) {
|
||||
final afterText = chunk.substring(lastEnd);
|
||||
if (afterText.trim().isNotEmpty) {
|
||||
contentWidgets.add(
|
||||
MarkdownBody(
|
||||
data: afterText,
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) => _handleMarkdownTap(text, href),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: contentWidgets,
|
||||
);
|
||||
}
|
||||
|
||||
return MarkdownBody(
|
||||
data: chunk,
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) => _handleMarkdownTap(text, href),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Output"),
|
||||
actions: [
|
||||
if (isSelectionMode) ...[
|
||||
TextButton.icon(
|
||||
onPressed: selectedChunks.isEmpty ? null : _saveSelectedChunks,
|
||||
icon: const Icon(Icons.save),
|
||||
label: Text('Save ${selectedChunks.length}'),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => setState(() {
|
||||
isSelectionMode = false;
|
||||
selectedChunks.clear();
|
||||
}),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
] else
|
||||
IconButton(
|
||||
onPressed: () => setState(() => isSelectionMode = true),
|
||||
icon: const Icon(Icons.checklist),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 800,
|
||||
),
|
||||
child: ListView.builder(
|
||||
itemCount:
|
||||
markdownChunks.length + (isStreaming || error != null ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == markdownChunks.length) {
|
||||
if (error != null) {
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Error loading content',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error!,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _retryStreaming,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildStreamingIndicator();
|
||||
}
|
||||
|
||||
final chunk =
|
||||
markdownChunks[index].replaceAll("---", "\n").trim();
|
||||
final isSelected = selectedChunks.contains(index);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: InkWell(
|
||||
onTap: isSelectionMode
|
||||
? () => setState(() {
|
||||
if (isSelected) {
|
||||
selectedChunks.remove(index);
|
||||
} else {
|
||||
selectedChunks.add(index);
|
||||
}
|
||||
})
|
||||
: null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (isSelectionMode)
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) => setState(() {
|
||||
if (value ?? false) {
|
||||
selectedChunks.add(index);
|
||||
} else {
|
||||
selectedChunks.remove(index);
|
||||
}
|
||||
}),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildMarkdownContent(chunk),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => _copyText(chunk),
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _deleteChunk(index),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStreamingIndicator() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Loading content...',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
class Marker {
|
||||
final Color color;
|
||||
final PdfTextRanges ranges;
|
||||
|
||||
Marker(this.color, this.ranges);
|
||||
}
|
||||
|
||||
class MarkersView extends StatefulWidget {
|
||||
const MarkersView({
|
||||
super.key,
|
||||
required this.markers,
|
||||
this.onTap,
|
||||
this.onDeleteTap,
|
||||
});
|
||||
|
||||
final List<Marker> markers;
|
||||
final void Function(Marker ranges)? onTap;
|
||||
final void Function(Marker ranges)? onDeleteTap;
|
||||
|
||||
@override
|
||||
State<MarkersView> createState() => _MarkersViewState();
|
||||
}
|
||||
|
||||
class _MarkersViewState extends State<MarkersView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
final marker = widget.markers[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: Stack(
|
||||
children: [
|
||||
Material(
|
||||
color: marker.color.withAlpha(100),
|
||||
child: InkWell(
|
||||
onTap: () => widget.onTap?.call(marker),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
child: Text(
|
||||
'Page #${marker.ranges.pageNumber} - ${marker.ranges.text}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => widget.onDeleteTap?.call(marker),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: widget.markers.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
//
|
||||
// Just a rough implementation of the document index
|
||||
//
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
class OutlineView extends StatelessWidget {
|
||||
const OutlineView({
|
||||
super.key,
|
||||
required this.outline,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final List<PdfOutlineNode>? outline;
|
||||
final PdfViewerController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final list = _getOutlineList(outline, 0).toList();
|
||||
return SizedBox(
|
||||
width: list.isEmpty ? 0 : 200,
|
||||
child: ListView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = list[index];
|
||||
return InkWell(
|
||||
onTap: () => controller.goToDest(item.node.dest),
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(
|
||||
left: item.level * 16.0 + 8,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Text(
|
||||
item.node.title,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Recursively create outline indent structure
|
||||
Iterable<({PdfOutlineNode node, int level})> _getOutlineList(
|
||||
List<PdfOutlineNode>? outline, int level) sync* {
|
||||
if (outline == null) return;
|
||||
for (var node in outline) {
|
||||
yield (node: node, level: level);
|
||||
yield* _getOutlineList(node.children, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
//
|
||||
// Simple password dialog
|
||||
//
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<String?> passwordDialog(BuildContext context) async {
|
||||
final textController = TextEditingController();
|
||||
return await showDialog<String?>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Enter password'),
|
||||
content: TextField(
|
||||
controller: textController,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
onSubmitted: (value) => Navigator.of(context).pop(value),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(textController.text),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:synchronized/extension.dart';
|
||||
|
||||
class TextSearchView extends StatefulWidget {
|
||||
const TextSearchView({
|
||||
super.key,
|
||||
required this.textSearcher,
|
||||
});
|
||||
|
||||
final PdfTextSearcher textSearcher;
|
||||
|
||||
@override
|
||||
State<TextSearchView> createState() => _TextSearchViewState();
|
||||
}
|
||||
|
||||
class _TextSearchViewState extends State<TextSearchView> {
|
||||
final focusNode = FocusNode();
|
||||
final searchTextController = TextEditingController();
|
||||
late final pageTextStore =
|
||||
PdfPageTextCache(textSearcher: widget.textSearcher);
|
||||
final scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
widget.textSearcher.addListener(_searchResultUpdated);
|
||||
searchTextController.addListener(_searchTextUpdated);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.dispose();
|
||||
widget.textSearcher.removeListener(_searchResultUpdated);
|
||||
searchTextController.removeListener(_searchTextUpdated);
|
||||
searchTextController.dispose();
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _searchTextUpdated() {
|
||||
widget.textSearcher.startTextSearch(searchTextController.text);
|
||||
}
|
||||
|
||||
int? _currentSearchSession;
|
||||
final _matchIndexToListIndex = <int>[];
|
||||
final _listIndexToMatchIndex = <int>[];
|
||||
|
||||
void _searchResultUpdated() {
|
||||
if (_currentSearchSession != widget.textSearcher.searchSession) {
|
||||
_currentSearchSession = widget.textSearcher.searchSession;
|
||||
_matchIndexToListIndex.clear();
|
||||
_listIndexToMatchIndex.clear();
|
||||
}
|
||||
for (int i = _matchIndexToListIndex.length;
|
||||
i < widget.textSearcher.matches.length;
|
||||
i++) {
|
||||
if (i == 0 ||
|
||||
widget.textSearcher.matches[i - 1].pageNumber !=
|
||||
widget.textSearcher.matches[i].pageNumber) {
|
||||
_listIndexToMatchIndex.add(-widget.textSearcher.matches[i]
|
||||
.pageNumber); // negative index to indicate page header
|
||||
}
|
||||
_matchIndexToListIndex.add(_listIndexToMatchIndex.length);
|
||||
_listIndexToMatchIndex.add(i);
|
||||
}
|
||||
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
static const double itemHeight = 50;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
widget.textSearcher.isSearching
|
||||
? LinearProgressIndicator(
|
||||
value: widget.textSearcher.searchProgress,
|
||||
minHeight: 4,
|
||||
)
|
||||
: const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
TextField(
|
||||
autofocus: true,
|
||||
focusNode: focusNode,
|
||||
controller: searchTextController,
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.only(right: 50),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (value) {
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
),
|
||||
if (widget.textSearcher.hasMatches)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'${widget.textSearcher.currentIndex! + 1} / ${widget.textSearcher.matches.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: (widget.textSearcher.currentIndex ?? 0) <
|
||||
widget.textSearcher.matches.length
|
||||
? () async {
|
||||
await widget.textSearcher.goToNextMatch();
|
||||
_conditionScrollPosition();
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.arrow_downward),
|
||||
iconSize: 20,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: (widget.textSearcher.currentIndex ?? 0) > 0
|
||||
? () async {
|
||||
await widget.textSearcher.goToPrevMatch();
|
||||
_conditionScrollPosition();
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
key: Key(searchTextController.text),
|
||||
controller: scrollController,
|
||||
itemCount: _listIndexToMatchIndex.length,
|
||||
itemBuilder: (context, index) {
|
||||
final matchIndex = _listIndexToMatchIndex[index];
|
||||
if (matchIndex >= 0 &&
|
||||
matchIndex < widget.textSearcher.matches.length) {
|
||||
final match = widget.textSearcher.matches[matchIndex];
|
||||
return SearchResultTile(
|
||||
key: ValueKey(index),
|
||||
match: match,
|
||||
onTap: () async {
|
||||
await widget.textSearcher.goToMatchOfIndex(matchIndex);
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
pageTextStore: pageTextStore,
|
||||
height: itemHeight,
|
||||
isCurrent: matchIndex == widget.textSearcher.currentIndex,
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
height: itemHeight,
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
'Page ${-matchIndex}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _conditionScrollPosition() {
|
||||
final pos = scrollController.position;
|
||||
final newPos =
|
||||
itemHeight * _matchIndexToListIndex[widget.textSearcher.currentIndex!];
|
||||
if (newPos + itemHeight > pos.pixels + pos.viewportDimension) {
|
||||
scrollController.animateTo(
|
||||
newPos + itemHeight - pos.viewportDimension,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.decelerate,
|
||||
);
|
||||
} else if (newPos < pos.pixels) {
|
||||
scrollController.animateTo(
|
||||
newPos,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.decelerate,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
class SearchResultTile extends StatefulWidget {
|
||||
const SearchResultTile({
|
||||
super.key,
|
||||
required this.match,
|
||||
required this.onTap,
|
||||
required this.pageTextStore,
|
||||
required this.height,
|
||||
required this.isCurrent,
|
||||
});
|
||||
|
||||
final PdfTextRangeWithFragments match;
|
||||
final void Function() onTap;
|
||||
final PdfPageTextCache pageTextStore;
|
||||
final double height;
|
||||
final bool isCurrent;
|
||||
|
||||
@override
|
||||
State<SearchResultTile> createState() => _SearchResultTileState();
|
||||
}
|
||||
|
||||
class _SearchResultTileState extends State<SearchResultTile> {
|
||||
PdfPageText? pageText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _release() {
|
||||
if (pageText != null) {
|
||||
widget.pageTextStore.releaseText(pageText!.pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
_release();
|
||||
pageText = await widget.pageTextStore.loadText(widget.match.pageNumber);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_release();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Text.rich(createTextSpanForMatch(pageText, widget.match));
|
||||
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: Material(
|
||||
color: widget.isCurrent
|
||||
? DefaultSelectionStyle.of(context).selectionColor!
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onTap(),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.black12,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(3),
|
||||
child: text,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan createTextSpanForMatch(
|
||||
PdfPageText? pageText, PdfTextRangeWithFragments match,
|
||||
{TextStyle? style}) {
|
||||
style ??= const TextStyle(
|
||||
fontSize: 14,
|
||||
);
|
||||
if (pageText == null) {
|
||||
return TextSpan(
|
||||
text: match.fragments.map((f) => f.text).join(),
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
final fullText = pageText.fullText;
|
||||
int first = 0;
|
||||
for (int i = match.fragments.first.index - 1; i >= 0;) {
|
||||
if (fullText[i] == '\n') {
|
||||
first = i + 1;
|
||||
break;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
int last = fullText.length;
|
||||
for (int i = match.fragments.last.end; i < fullText.length; i++) {
|
||||
if (fullText[i] == '\n') {
|
||||
last = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final header =
|
||||
fullText.substring(first, match.fragments.first.index + match.start);
|
||||
final body = fullText.substring(match.fragments.first.index + match.start,
|
||||
match.fragments.last.index + match.end);
|
||||
final footer =
|
||||
fullText.substring(match.fragments.last.index + match.end, last);
|
||||
|
||||
return TextSpan(
|
||||
children: [
|
||||
TextSpan(text: header),
|
||||
TextSpan(
|
||||
text: body,
|
||||
style: const TextStyle(
|
||||
backgroundColor: Colors.yellow,
|
||||
),
|
||||
),
|
||||
TextSpan(text: footer),
|
||||
],
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper class to cache loaded page texts.
|
||||
class PdfPageTextCache {
|
||||
final PdfTextSearcher textSearcher;
|
||||
PdfPageTextCache({
|
||||
required this.textSearcher,
|
||||
});
|
||||
|
||||
final _pageTextRefs = <int, _PdfPageTextRefCount>{};
|
||||
|
||||
/// load the text of the given page number.
|
||||
Future<PdfPageText> loadText(int pageNumber) async {
|
||||
final ref = _pageTextRefs[pageNumber];
|
||||
if (ref != null) {
|
||||
ref.refCount++;
|
||||
return ref.pageText;
|
||||
}
|
||||
return await synchronized(() async {
|
||||
var ref = _pageTextRefs[pageNumber];
|
||||
if (ref == null) {
|
||||
final pageText = await textSearcher.loadText(pageNumber: pageNumber);
|
||||
ref = _pageTextRefs[pageNumber] = _PdfPageTextRefCount(pageText!);
|
||||
}
|
||||
ref.refCount++;
|
||||
return ref.pageText;
|
||||
});
|
||||
}
|
||||
|
||||
/// Release the text of the given page number.
|
||||
void releaseText(int pageNumber) {
|
||||
final ref = _pageTextRefs[pageNumber]!;
|
||||
ref.refCount--;
|
||||
if (ref.refCount == 0) {
|
||||
_pageTextRefs.remove(pageNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PdfPageTextRefCount {
|
||||
_PdfPageTextRefCount(this.pageText);
|
||||
final PdfPageText pageText;
|
||||
int refCount = 0;
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
//
|
||||
// Super simple thumbnails view
|
||||
//
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
class ThumbnailsView extends StatelessWidget {
|
||||
const ThumbnailsView(
|
||||
{super.key, required this.documentRef, required this.controller});
|
||||
|
||||
final PdfDocumentRef? documentRef;
|
||||
final PdfViewerController? controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.grey,
|
||||
child: documentRef == null
|
||||
? null
|
||||
: PdfDocumentViewBuilder(
|
||||
documentRef: documentRef!,
|
||||
builder: (context, document) => ListView.builder(
|
||||
itemCount: document?.pages.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(8),
|
||||
height: 240,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: InkWell(
|
||||
onTap: () => controller!.goToPage(
|
||||
pageNumber: index + 1,
|
||||
anchor: PdfPageAnchor.top,
|
||||
),
|
||||
child: PdfPageView(
|
||||
document: document,
|
||||
pageNumber: index + 1,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${index + 1}',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,389 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/pdf/magic_show_markdown.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'pdf/magic_bottom_sheet.dart';
|
||||
import 'pdf/markers_view.dart';
|
||||
import 'pdf/outline_view.dart';
|
||||
import 'pdf/password_dialog.dart';
|
||||
import 'pdf/search_view.dart';
|
||||
|
||||
class PDFViewerContainer extends StatefulWidget {
|
||||
final DocSource source;
|
||||
|
||||
const PDFViewerContainer({
|
||||
super.key,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PDFViewerContainer> createState() => _PDFViewerContainerState();
|
||||
}
|
||||
|
||||
class _PDFViewerContainerState extends State<PDFViewerContainer> {
|
||||
final documentRef = ValueNotifier<PdfDocumentRef?>(null);
|
||||
final controller = PdfViewerController();
|
||||
final showLeftPane = ValueNotifier<bool>(false);
|
||||
final outline = ValueNotifier<List<PdfOutlineNode>?>(null);
|
||||
late final textSearcher = PdfTextSearcher(controller)..addListener(_update);
|
||||
final _markers = <int, List<Marker>>{};
|
||||
List<PdfTextRanges>? _textSelections;
|
||||
|
||||
void _update() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
textSearcher.removeListener(_update);
|
||||
textSearcher.dispose();
|
||||
showLeftPane.dispose();
|
||||
outline.dispose();
|
||||
documentRef.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: Text(
|
||||
widget.source.title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.list),
|
||||
onPressed: () {
|
||||
showLeftPane.value = !showLeftPane.value;
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: search(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.search,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(Icons.zoom_in),
|
||||
onPressed: () => controller.zoomUp(),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(Icons.zoom_out),
|
||||
onPressed: () => controller.zoomDown(),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
FloatingActionButton.extended(
|
||||
label: const Text("Magic"),
|
||||
onPressed: () async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return MagicBottomSheet(
|
||||
controller: controller,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result == null ||
|
||||
!context.mounted ||
|
||||
(result is List) && result.length != 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result[1] == null || result[0] == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) {
|
||||
return MagicShowMarkdown(
|
||||
record: result[0] as RecordModel,
|
||||
pages: result[1] as List<int>,
|
||||
controller: controller,
|
||||
fileName: widget.source.title,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.auto_awesome,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: showLeftPane,
|
||||
builder: (context, showLeftPane, child) => SizedBox(
|
||||
width: showLeftPane ? 300 : 0,
|
||||
child: child!,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(1, 0, 4, 0),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: outline,
|
||||
builder: (context, outline, child) => OutlineView(
|
||||
outline: outline,
|
||||
controller: controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (widget.source is FileSource)
|
||||
PdfViewer.file(
|
||||
(widget.source as FileSource).filePath,
|
||||
passwordProvider: () => passwordDialog(context),
|
||||
controller: controller,
|
||||
params: params,
|
||||
),
|
||||
if (widget.source is URLSource)
|
||||
PdfViewer.uri(
|
||||
Uri.parse((widget.source as URLSource).url),
|
||||
passwordProvider: () => passwordDialog(context),
|
||||
headers: (widget.source as URLSource).headers,
|
||||
controller: controller,
|
||||
params: params,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget search() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: documentRef,
|
||||
builder: (context, documentRef, child) => TextSearchView(
|
||||
textSearcher: textSearcher,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PdfViewerParams get params {
|
||||
return PdfViewerParams(
|
||||
enableTextSelection: true,
|
||||
maxScale: 8,
|
||||
onViewSizeChanged: (viewSize, oldViewSize, controller) {
|
||||
if (oldViewSize != null) {
|
||||
final centerPosition = controller.value.calcPosition(oldViewSize);
|
||||
final newMatrix = controller.calcMatrixFor(centerPosition);
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 200),
|
||||
() => controller.goTo(newMatrix),
|
||||
);
|
||||
}
|
||||
},
|
||||
viewerOverlayBuilder: (context, size, handleLinkTap) => [
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapUp: (details) {
|
||||
handleLinkTap(details.localPosition);
|
||||
},
|
||||
onDoubleTap: () {
|
||||
if (controller.currentZoom <= 1) {
|
||||
controller.zoomUp(loop: true);
|
||||
} else {
|
||||
controller.zoomDown(
|
||||
loop: false,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: IgnorePointer(
|
||||
child: SizedBox(width: size.width, height: size.height),
|
||||
),
|
||||
),
|
||||
PdfViewerScrollThumb(
|
||||
controller: controller,
|
||||
orientation: ScrollbarOrientation.right,
|
||||
thumbSize: const Size(44, 28),
|
||||
thumbBuilder: (context, thumbSize, pageNumber, controller) =>
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
const Icon(
|
||||
Icons.drag_indicator,
|
||||
size: 14,
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
pageNumber.toString(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PdfViewerScrollThumb(
|
||||
controller: controller,
|
||||
orientation: ScrollbarOrientation.bottom,
|
||||
thumbSize: const Size(80, 22),
|
||||
thumbBuilder: (context, thumbSize, pageNumber, controller) =>
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.drag_indicator_outlined,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
loadingBannerBuilder: (context, bytesDownloaded, totalBytes) => Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: totalBytes != null ? bytesDownloaded / totalBytes : null,
|
||||
backgroundColor: Colors.grey,
|
||||
),
|
||||
),
|
||||
linkHandlerParams: PdfLinkHandlerParams(
|
||||
onLinkTap: (link) {
|
||||
if (link.url != null) {
|
||||
navigateToUrl(link.url!);
|
||||
} else if (link.dest != null) {
|
||||
controller.goToDest(link.dest);
|
||||
}
|
||||
},
|
||||
),
|
||||
pagePaintCallbacks: [
|
||||
textSearcher.pageTextMatchPaintCallback,
|
||||
_paintMarkers,
|
||||
],
|
||||
onDocumentChanged: (document) async {
|
||||
if (document == null) {
|
||||
documentRef.value = null;
|
||||
outline.value = null;
|
||||
_textSelections = null;
|
||||
_markers.clear();
|
||||
}
|
||||
},
|
||||
onViewerReady: (document, controller) async {
|
||||
documentRef.value = controller.documentRef;
|
||||
outline.value = await document.loadOutline();
|
||||
},
|
||||
onTextSelectionChange: (selections) {
|
||||
_textSelections = selections;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _paintMarkers(Canvas canvas, Rect pageRect, PdfPage page) {
|
||||
final markers = _markers[page.pageNumber];
|
||||
if (markers == null) {
|
||||
return;
|
||||
}
|
||||
for (final marker in markers) {
|
||||
final paint = Paint()
|
||||
..color = marker.color.withAlpha(100)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
for (final range in marker.ranges.ranges) {
|
||||
final f = PdfTextRangeWithFragments.fromTextRange(
|
||||
marker.ranges.pageText,
|
||||
range.start,
|
||||
range.end,
|
||||
);
|
||||
if (f != null) {
|
||||
canvas.drawRect(
|
||||
f.bounds.toRectInPageRect(page: page, pageRect: pageRect),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> navigateToUrl(Uri url) async {
|
||||
if (await shouldOpenUrl(context, url)) {
|
||||
await launchUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> shouldOpenUrl(BuildContext context, Uri url) async {
|
||||
final result = await showDialog<bool?>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Navigate to URL?'),
|
||||
content: SelectionArea(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
const TextSpan(
|
||||
text:
|
||||
'Do you want to navigate to the following location?\n'),
|
||||
TextSpan(
|
||||
text: url.toString(),
|
||||
style: const TextStyle(color: Colors.blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Go'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
class PhotoViewer extends StatelessWidget {
|
||||
final DocSource source;
|
||||
const PhotoViewer({
|
||||
super.key,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ImageProvider provider;
|
||||
|
||||
if (source is FileSource) {
|
||||
provider = FileImage(File((source as FileSource).filePath));
|
||||
} else if (source is URLSource) {
|
||||
provider = NetworkImage((source as URLSource).url);
|
||||
} else {
|
||||
throw TypeError();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(source.title),
|
||||
),
|
||||
body: PhotoView(
|
||||
imageProvider: provider,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,422 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/watch_history/service/base_watch_history.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../../utils/load_language.dart';
|
||||
import '../../connections/types/stremio/stremio_base.types.dart' as types;
|
||||
import '../../trakt/service/trakt.service.dart';
|
||||
import '../../watch_history/service/zeee_watch_history.dart';
|
||||
import '../types/doc_source.dart';
|
||||
import 'video_viewer/video_viewer_ui.dart';
|
||||
|
||||
class VideoViewer extends StatefulWidget {
|
||||
final DocSource source;
|
||||
final LibraryItem? meta;
|
||||
final BaseConnectionService? service;
|
||||
final String? currentSeason;
|
||||
final String? library;
|
||||
|
||||
const VideoViewer({
|
||||
super.key,
|
||||
required this.source,
|
||||
this.meta,
|
||||
this.service,
|
||||
this.currentSeason,
|
||||
this.library,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoViewer> createState() => _VideoViewerState();
|
||||
}
|
||||
|
||||
class _VideoViewerState extends State<VideoViewer> {
|
||||
late LibraryItem? meta = widget.meta;
|
||||
|
||||
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
|
||||
Timer? _timer;
|
||||
late final Player player = Player(
|
||||
configuration: const PlayerConfiguration(
|
||||
title: "Madari",
|
||||
),
|
||||
);
|
||||
final Logger _logger = Logger('VideoPlayer');
|
||||
|
||||
double get currentProgressInPercentage {
|
||||
final duration = player.state.duration.inSeconds;
|
||||
final position = player.state.position.inSeconds;
|
||||
return duration > 0 ? (position / duration * 100) : 0;
|
||||
}
|
||||
|
||||
bool timeLoaded = false;
|
||||
|
||||
Future<types.Meta>? traktProgress;
|
||||
|
||||
Future<void> saveWatchHistory() async {
|
||||
_logger.info('Starting to save watch history...');
|
||||
|
||||
final duration = player.state.duration.inSeconds;
|
||||
|
||||
if (duration <= 30) {
|
||||
_logger.info('Video is too short to track.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (gotFromTraktDuration == false) {
|
||||
_logger.info(
|
||||
"Did not start the scrobbling because initially time is not retrieved from the API.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final position = player.state.position.inSeconds;
|
||||
final progress = duration > 0 ? (position / duration * 100) : 0;
|
||||
|
||||
if (progress < 0.01) {
|
||||
_logger.info('No progress to save.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta is types.Meta && TraktService.instance != null) {
|
||||
try {
|
||||
if (player.state.playing) {
|
||||
_logger.info('Starting scrobbling...');
|
||||
await TraktService.instance!.startScrobbling(
|
||||
meta: meta as types.Meta,
|
||||
progress: currentProgressInPercentage,
|
||||
);
|
||||
} else {
|
||||
_logger.info('Stopping scrobbling...');
|
||||
await TraktService.instance!.stopScrobbling(
|
||||
meta: meta as types.Meta,
|
||||
progress: currentProgressInPercentage,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe('Error during scrobbling: $e');
|
||||
TraktService.instance!.debugLogs.add(e.toString());
|
||||
}
|
||||
} else {
|
||||
_logger.warning('Meta is not valid or TraktService is not initialized.');
|
||||
}
|
||||
|
||||
await zeeeWatchHistory!.saveWatchHistory(
|
||||
history: WatchHistory(
|
||||
id: _source.id,
|
||||
progress: progress.round(),
|
||||
duration: duration.toDouble(),
|
||||
episode: _source.episode,
|
||||
season: _source.season,
|
||||
),
|
||||
);
|
||||
|
||||
_logger.info('Watch history saved successfully.');
|
||||
}
|
||||
|
||||
late final controller = VideoController(
|
||||
player,
|
||||
configuration: VideoControllerConfiguration(
|
||||
enableHardwareAcceleration: !config.softwareAcceleration,
|
||||
),
|
||||
);
|
||||
|
||||
late DocSource _source;
|
||||
|
||||
bool gotFromTraktDuration = false;
|
||||
|
||||
int? traktId;
|
||||
|
||||
Future<void> setDurationFromTrakt({
|
||||
Future<types.Meta>? traktProgress,
|
||||
}) async {
|
||||
_logger.info('Setting duration from Trakt...');
|
||||
|
||||
try {
|
||||
if (player.state.duration.inSeconds < 2) {
|
||||
_logger.info('Duration is too short to set from Trakt.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (gotFromTraktDuration) {
|
||||
_logger.info('Duration already set from Trakt.');
|
||||
return;
|
||||
}
|
||||
|
||||
gotFromTraktDuration = true;
|
||||
|
||||
if (!TraktService.isEnabled() ||
|
||||
(traktProgress ?? this.traktProgress) == null) {
|
||||
_logger.info(
|
||||
'Trakt service is not enabled or progress is null. Playing video.');
|
||||
player.play();
|
||||
return;
|
||||
}
|
||||
|
||||
final progress = await (traktProgress ?? this.traktProgress);
|
||||
|
||||
if (this.meta is! types.Meta) {
|
||||
_logger.info('Meta is not of type types.Meta.');
|
||||
return;
|
||||
}
|
||||
|
||||
final meta = (progress ?? this.meta) as types.Meta;
|
||||
|
||||
final duration = Duration(
|
||||
seconds: calculateSecondsFromProgress(
|
||||
player.state.duration.inSeconds.toDouble(),
|
||||
meta.currentVideo?.progress ?? meta.progress ?? 0,
|
||||
),
|
||||
);
|
||||
|
||||
if (duration.inSeconds > 10) {
|
||||
_logger.info('Seeking to duration: $duration');
|
||||
await player.seek(duration);
|
||||
}
|
||||
|
||||
await player.play();
|
||||
_logger.info('Video started playing.');
|
||||
} catch (e) {
|
||||
_logger.severe('Error setting duration from Trakt: $e');
|
||||
await player.play();
|
||||
}
|
||||
}
|
||||
|
||||
List<StreamSubscription> listener = [];
|
||||
|
||||
PlaybackConfig config = getPlaybackConfig();
|
||||
|
||||
Future setupVideoThings() async {
|
||||
_logger.info('Setting up video things...');
|
||||
|
||||
if (TraktService.isEnabled()) {
|
||||
traktProgress = null;
|
||||
traktProgress = TraktService.instance!.getProgress(
|
||||
meta as types.Meta,
|
||||
bypassCache: true,
|
||||
);
|
||||
}
|
||||
|
||||
_duration = player.stream.duration.listen((item) async {
|
||||
if (meta is types.Meta) {
|
||||
setDurationFromTrakt(traktProgress: traktProgress);
|
||||
}
|
||||
|
||||
if (item.inSeconds != 0) {
|
||||
_logger.info('Duration updated: $item');
|
||||
await saveWatchHistory();
|
||||
}
|
||||
});
|
||||
|
||||
_timer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
_logger.info('Periodic save watch history triggered.');
|
||||
saveWatchHistory();
|
||||
});
|
||||
|
||||
_streamListen = player.stream.playing.listen((playing) {
|
||||
_logger.info('Playing state changed: $playing');
|
||||
saveWatchHistory();
|
||||
});
|
||||
|
||||
_logger.info('Loading file...');
|
||||
|
||||
return loadFile();
|
||||
}
|
||||
|
||||
destroyVideoThing() async {
|
||||
_logger.info('Destroying video things...');
|
||||
|
||||
timeLoaded = false;
|
||||
gotFromTraktDuration = false;
|
||||
traktProgress = null;
|
||||
|
||||
for (final item in listener) {
|
||||
item.cancel();
|
||||
}
|
||||
listener = [];
|
||||
_timer?.cancel();
|
||||
_streamListen?.cancel();
|
||||
_duration?.cancel();
|
||||
|
||||
if (meta is types.Meta && player.state.duration.inSeconds > 30) {
|
||||
_logger.info('Stopping scrobbling and clearing cache...');
|
||||
await TraktService.instance!.stopScrobbling(
|
||||
meta: meta as types.Meta,
|
||||
progress: currentProgressInPercentage,
|
||||
shouldClearCache: true,
|
||||
traktId: traktId,
|
||||
);
|
||||
}
|
||||
|
||||
_logger.info('Video things destroyed.');
|
||||
}
|
||||
|
||||
GlobalKey videoKey = GlobalKey();
|
||||
|
||||
generateNewKey() {
|
||||
_logger.info('Generating new key...');
|
||||
videoKey = GlobalKey();
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_logger.info('Initializing VideoViewer...');
|
||||
|
||||
_source = widget.source;
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
overlays: [],
|
||||
);
|
||||
|
||||
if (player.platform is NativePlayer && !kIsWeb) {
|
||||
Future.microtask(() async {
|
||||
_logger.info('Setting network timeout...');
|
||||
await (player.platform as dynamic).setProperty('network-timeout', '60');
|
||||
});
|
||||
}
|
||||
|
||||
onVideoChange(
|
||||
_source,
|
||||
widget.meta!,
|
||||
);
|
||||
|
||||
_logger.info('VideoViewer initialized.');
|
||||
}
|
||||
|
||||
Future<void> loadFile() async {
|
||||
_logger.info('Loading file...');
|
||||
|
||||
Duration duration = const Duration(seconds: 0);
|
||||
|
||||
if (meta is types.Meta && TraktService.isEnabled()) {
|
||||
_logger.info("Playing video ${(meta as types.Meta).selectedVideoIndex}");
|
||||
} else {
|
||||
final item = await zeeeWatchHistory!.getItemWatchHistory(
|
||||
ids: [
|
||||
WatchHistoryGetRequest(
|
||||
id: _source.id,
|
||||
season: _source.season,
|
||||
episode: _source.episode,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
duration = Duration(
|
||||
seconds: item.isEmpty
|
||||
? 0
|
||||
: calculateSecondsFromProgress(
|
||||
item.first.duration,
|
||||
item.first.progress.toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.info('Loading file for source: ${_source.id}');
|
||||
|
||||
switch (_source.runtimeType) {
|
||||
case const (FileSource):
|
||||
if (kIsWeb) {
|
||||
_logger.info('FileSource is not supported on web.');
|
||||
return;
|
||||
}
|
||||
player.open(
|
||||
Media(
|
||||
(_source as FileSource).filePath,
|
||||
start: duration,
|
||||
),
|
||||
play: false,
|
||||
);
|
||||
case const (URLSource):
|
||||
case const (MediaURLSource):
|
||||
case const (TorrentSource):
|
||||
player.open(
|
||||
Media(
|
||||
(_source as URLSource).url,
|
||||
httpHeaders: (_source as URLSource).headers,
|
||||
start: duration,
|
||||
),
|
||||
play: false,
|
||||
);
|
||||
}
|
||||
|
||||
_logger.info('File loaded successfully.');
|
||||
}
|
||||
|
||||
StreamSubscription<bool>? _streamListen;
|
||||
StreamSubscription<dynamic>? _duration;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logger.info('Disposing VideoViewer...');
|
||||
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.edgeToEdge,
|
||||
overlays: [],
|
||||
);
|
||||
|
||||
destroyVideoThing();
|
||||
player.dispose();
|
||||
|
||||
super.dispose();
|
||||
|
||||
_logger.info('VideoViewer disposed.');
|
||||
}
|
||||
|
||||
onVideoChange(DocSource source, LibraryItem item) async {
|
||||
setState(() {});
|
||||
await destroyVideoThing();
|
||||
|
||||
_logger.info('Changing video source...');
|
||||
|
||||
_source = source;
|
||||
meta = item;
|
||||
setState(() {});
|
||||
await setupVideoThings();
|
||||
setState(() {});
|
||||
generateNewKey();
|
||||
|
||||
_logger.info('Video source changed successfully.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: VideoViewerUi(
|
||||
key: videoKey,
|
||||
controller: controller,
|
||||
player: player,
|
||||
config: config,
|
||||
source: _source,
|
||||
onLibrarySelect: () {},
|
||||
service: widget.service,
|
||||
meta: meta,
|
||||
onSourceChange: (source, meta) => onVideoChange(source, meta),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int calculateSecondsFromProgress(
|
||||
double duration,
|
||||
double progressPercentage,
|
||||
) {
|
||||
final clampedProgress = progressPercentage.clamp(0.0, 100.0);
|
||||
final currentSeconds = (duration * (clampedProgress / 100)).round();
|
||||
return currentSeconds;
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/utils/load_language.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
class AudioTrackSelector extends StatefulWidget {
|
||||
final Player player;
|
||||
final PlaybackConfig config;
|
||||
|
||||
const AudioTrackSelector({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AudioTrackSelector> createState() => _AudioTrackSelectorState();
|
||||
}
|
||||
|
||||
class _AudioTrackSelectorState extends State<AudioTrackSelector> {
|
||||
List<AudioTrack> audioTracks = [];
|
||||
Map<String, String> languages = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
audioTracks = widget.player.state.tracks.audio.where((item) {
|
||||
return item.id != "auto" && item.id != "no";
|
||||
}).toList();
|
||||
|
||||
loadLanguages(context).then((language) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
languages = language;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Audio Track',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: audioTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentItem = audioTracks[index];
|
||||
final title = currentItem.language ??
|
||||
currentItem.title ??
|
||||
currentItem.id;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
languages.containsKey(title) ? languages[title]! : title,
|
||||
),
|
||||
selected:
|
||||
widget.player.state.track.audio.id == currentItem.id,
|
||||
onTap: () {
|
||||
widget.player.setAudioTrack(currentItem);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart';
|
||||
|
||||
MaterialDesktopVideoControlsThemeData getDesktopControls(
|
||||
BuildContext context, {
|
||||
required DocSource source,
|
||||
required Player player,
|
||||
Widget? library,
|
||||
required Function() onSubtitleSelect,
|
||||
required Function() onAudioSelect,
|
||||
LibraryItem? meta,
|
||||
required Function(int index) onVideoChange,
|
||||
}) {
|
||||
return MaterialDesktopVideoControlsThemeData(
|
||||
toggleFullscreenOnDoublePress: true,
|
||||
displaySeekBar: true,
|
||||
topButtonBar: [
|
||||
SafeArea(
|
||||
child: MaterialDesktopCustomButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width - 120,
|
||||
child: Text(
|
||||
(meta is Meta && meta.currentVideo != null)
|
||||
? "${meta.name ?? ""} S${meta.currentVideo?.season} E${meta.currentVideo?.episode}"
|
||||
: source.title.endsWith(".mp4")
|
||||
? source.title.substring(0, source.title.length - 4)
|
||||
: source.title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (meta is Meta)
|
||||
if (meta.type == "series")
|
||||
SeasonSource(
|
||||
meta: meta,
|
||||
isMobile: false,
|
||||
player: player,
|
||||
onVideoChange: onVideoChange,
|
||||
),
|
||||
],
|
||||
bufferingIndicatorBuilder: source is TorrentSource
|
||||
? (ctx) {
|
||||
return TorrentStats(
|
||||
torrentHash: source.infoHash,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
playAndPauseOnTap: true,
|
||||
bottomButtonBar: [
|
||||
const MaterialDesktopSkipPreviousButton(),
|
||||
const MaterialDesktopPlayOrPauseButton(),
|
||||
const MaterialDesktopSkipNextButton(),
|
||||
const MaterialDesktopVolumeButton(),
|
||||
const MaterialDesktopPositionIndicator(),
|
||||
const Spacer(),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
final speeds = [
|
||||
0.5,
|
||||
0.75,
|
||||
1.0,
|
||||
1.25,
|
||||
1.5,
|
||||
1.75,
|
||||
2.0,
|
||||
2.5,
|
||||
3.0,
|
||||
3.5,
|
||||
4.0,
|
||||
4.5,
|
||||
5.0
|
||||
];
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => Card(
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Playback Speed',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: speeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final speed = speeds[index];
|
||||
return ListTile(
|
||||
title: Text('${speed}x'),
|
||||
selected: player.state.rate == speed,
|
||||
onTap: () {
|
||||
player.setRate(speed);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.speed),
|
||||
),
|
||||
MaterialDesktopCustomButton(
|
||||
onPressed: onSubtitleSelect,
|
||||
icon: const Icon(Icons.subtitles),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
MaterialDesktopCustomButton(
|
||||
onPressed: onAudioSelect,
|
||||
icon: const Icon(Icons.audiotrack),
|
||||
),
|
||||
if (!kIsWeb &&
|
||||
(Platform.isLinux || Platform.isWindows || Platform.isMacOS))
|
||||
const AlwaysOnTopButton(),
|
||||
const MaterialDesktopFullscreenButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class AlwaysOnTopButton extends StatefulWidget {
|
||||
const AlwaysOnTopButton({super.key});
|
||||
|
||||
@override
|
||||
State<AlwaysOnTopButton> createState() => _AlwaysOnTopButtonState();
|
||||
}
|
||||
|
||||
class _AlwaysOnTopButtonState extends State<AlwaysOnTopButton> {
|
||||
bool alwaysOnTop = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
windowManager.isAlwaysOnTop().then((value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
alwaysOnTop = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: "Always on top",
|
||||
child: MaterialDesktopCustomButton(
|
||||
onPressed: () async {
|
||||
if (await windowManager.isAlwaysOnTop()) {
|
||||
windowManager.setAlwaysOnTop(false);
|
||||
windowManager.setTitleBarStyle(TitleBarStyle.normal);
|
||||
setState(() {
|
||||
alwaysOnTop = false;
|
||||
});
|
||||
windowManager.setVisibleOnAllWorkspaces(false);
|
||||
} else {
|
||||
windowManager.setAlwaysOnTop(true);
|
||||
windowManager.setVisibleOnAllWorkspaces(true);
|
||||
windowManager.setTitleBarStyle(TitleBarStyle.hidden);
|
||||
setState(() {
|
||||
alwaysOnTop = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
alwaysOnTop ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
),
|
||||
iconSize: 22,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart';
|
||||
|
||||
class SeasonSource extends StatelessWidget {
|
||||
final Meta meta;
|
||||
final bool isMobile;
|
||||
final Player player;
|
||||
final Function(int index) onVideoChange;
|
||||
|
||||
const SeasonSource({
|
||||
super.key,
|
||||
required this.meta,
|
||||
required this.isMobile,
|
||||
required this.player,
|
||||
required this.onVideoChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialCustomButton(
|
||||
onPressed: () => onSelectMobile(context),
|
||||
icon: const Icon(Icons.list_alt),
|
||||
);
|
||||
}
|
||||
|
||||
onSelectDesktop(BuildContext context) {
|
||||
showCupertinoDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return VideoSelectView(
|
||||
meta: meta,
|
||||
onVideoChange: onVideoChange,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
onSelectMobile(BuildContext context) {
|
||||
showCupertinoDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return VideoSelectView(
|
||||
meta: meta,
|
||||
onVideoChange: onVideoChange,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoSelectView extends StatefulWidget {
|
||||
final Meta meta;
|
||||
final Function(int index) onVideoChange;
|
||||
|
||||
const VideoSelectView({
|
||||
super.key,
|
||||
required this.meta,
|
||||
required this.onVideoChange,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoSelectView> createState() => _VideoSelectViewState();
|
||||
}
|
||||
|
||||
class _VideoSelectViewState extends State<VideoSelectView> {
|
||||
final ScrollController controller = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.meta.selectedVideoIndex != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
const itemWidth = 240.0 + 16.0;
|
||||
final offset = widget.meta.selectedVideoIndex! * itemWidth;
|
||||
|
||||
controller.jumpTo(offset);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity! > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black38,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
title: const Text("Episodes"),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
final video = widget.meta.videos![index];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
widget.onVideoChange(index);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
fit: BoxFit.fill,
|
||||
image: CachedNetworkImageProvider(
|
||||
video.thumbnail ??
|
||||
widget.meta.poster ??
|
||||
widget.meta.background ??
|
||||
""),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 240,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black54,
|
||||
Colors.black38,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"S${video.season} E${video.episode}",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
video.name ?? video.title ?? "",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.meta.selectedVideoIndex == index)
|
||||
Positioned(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black54,
|
||||
Colors.black38,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text("Playing"),
|
||||
Icon(Icons.play_arrow),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: (widget.meta.videos ?? []).length,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/connections/service/stremio_connection_service.dart';
|
||||
import 'package:madari_client/utils/load_language.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../connections/service/base_connection_service.dart';
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart';
|
||||
|
||||
Map<String, List<Subtitle>> externalSubtitlesCache = {};
|
||||
|
||||
class SubtitleSelector extends StatefulWidget {
|
||||
final Player player;
|
||||
final PlaybackConfig config;
|
||||
final BaseConnectionService? service;
|
||||
final LibraryItem? meta;
|
||||
|
||||
const SubtitleSelector({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.config,
|
||||
required this.service,
|
||||
this.meta,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SubtitleSelector> createState() => _SubtitleSelectorState();
|
||||
}
|
||||
|
||||
class _SubtitleSelectorState extends State<SubtitleSelector> {
|
||||
List<SubtitleTrack> subtitles = [];
|
||||
Map<String, String> languages = {};
|
||||
Stream<List<Subtitle>>? externalSubtitles;
|
||||
|
||||
late StreamSubscription<List<String>> _subtitles;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.service is StremioConnectionService && widget.meta is Meta) {
|
||||
final meta = widget.meta as Meta;
|
||||
|
||||
if (externalSubtitlesCache.containsKey(meta.id)) {
|
||||
externalSubtitles = Stream.value(externalSubtitlesCache[meta.id]!);
|
||||
} else {
|
||||
externalSubtitles = (widget.service as StremioConnectionService)
|
||||
.getSubtitles(meta)
|
||||
.map((item) {
|
||||
externalSubtitlesCache[meta.id] = item;
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onPlaybackReady(widget.player.state.tracks);
|
||||
_subtitles = widget.player.stream.subtitle.listen((item) {
|
||||
onPlaybackReady(widget.player.state.tracks);
|
||||
});
|
||||
|
||||
loadLanguages(context).then((language) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
languages = language;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
_subtitles.cancel();
|
||||
}
|
||||
|
||||
void onPlaybackReady(Tracks tracks) {
|
||||
setState(() {
|
||||
subtitles = tracks.subtitle.where((item) {
|
||||
return item.id != "auto" && item.id != "no";
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 520,
|
||||
),
|
||||
child: Card(
|
||||
child: Container(
|
||||
height: max(MediaQuery.of(context).size.height * 0.4, 400),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Subtitle',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Subtitle>>(
|
||||
stream: externalSubtitles,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Colors.black54,
|
||||
highlightColor: Colors.black54,
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
title: Container(
|
||||
height: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return ListView.builder(
|
||||
itemCount: subtitles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentItem = subtitles[index];
|
||||
final title = currentItem.language ??
|
||||
currentItem.title ??
|
||||
currentItem.id;
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
languages.containsKey(title)
|
||||
? languages[title]!
|
||||
: title,
|
||||
),
|
||||
selected: widget.player.state.track.subtitle.id ==
|
||||
currentItem.id,
|
||||
onTap: () {
|
||||
widget.player.setSubtitleTrack(currentItem);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
final externalSubtitlesList = snapshot.data!;
|
||||
final allSubtitles = [
|
||||
SubtitleTrack.no(),
|
||||
...subtitles,
|
||||
...externalSubtitlesList.map(
|
||||
(subtitle) {
|
||||
return SubtitleTrack.uri(
|
||||
subtitle.url,
|
||||
language: subtitle.lang,
|
||||
title:
|
||||
"${languages[subtitle.lang] ?? subtitle.lang} ${subtitle.id}",
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: allSubtitles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentItem = allSubtitles[index];
|
||||
final title = currentItem.language ??
|
||||
currentItem.title ??
|
||||
currentItem.id;
|
||||
|
||||
final isExternal = currentItem.uri;
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
"${languages.containsKey(title) ? languages[title]! : title == "no" ? "No subtitle" : title} ${isExternal ? "(External) (${Uri.parse(currentItem.id).host})" : ""}",
|
||||
),
|
||||
selected: widget.player.state.track.subtitle.id ==
|
||||
currentItem.id,
|
||||
onTap: () async {
|
||||
await widget.player.setSubtitleTrack(currentItem);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,527 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'torrent_stat.g.dart';
|
||||
|
||||
class TorrentStats extends StatefulWidget {
|
||||
final String torrentHash;
|
||||
|
||||
const TorrentStats({
|
||||
super.key,
|
||||
required this.torrentHash,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TorrentStats> createState() => _TorrentStatsState();
|
||||
}
|
||||
|
||||
class _TorrentStatsState extends State<TorrentStats> {
|
||||
late Timer _timer;
|
||||
TorrentStat? stat;
|
||||
bool hasOpenOnce = false;
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) {
|
||||
getStats();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getStats() async {
|
||||
final result = await http.get(
|
||||
Uri.parse(
|
||||
"http://localhost:64544/torrents/${widget.torrentHash}/stats/v1"),
|
||||
);
|
||||
|
||||
final data = TorrentStat.fromJson(jsonDecode(result.body));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
stat = data;
|
||||
hasOpenOnce = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
_timer.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = MediaQuery.of(context);
|
||||
final isSmallScreen = media.size.width < 600;
|
||||
|
||||
if (stat == null) {
|
||||
return Container(
|
||||
width: min(media.size.width, 800),
|
||||
height: min(media.size.height, 180),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: min(media.size.width, 800),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isSmallScreen ? 12.0 : 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Status and Progress Row
|
||||
if (stat?.live != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
// Status Indicator
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: stat?.state == 'Downloading'
|
||||
? Colors.green
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
stat?.state ?? 'Loading...',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${((stat!.progressBytes / stat!.totalBytes) * 100).toStringAsFixed(1)}%',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE50914),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Progress Bar
|
||||
LinearProgressIndicator(
|
||||
value: stat!.progressBytes / stat!.totalBytes,
|
||||
backgroundColor: Colors.grey[800],
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation<Color>(Color(0xFFE50914)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Main Stats Grid
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_buildCompactStat(
|
||||
Icons.download,
|
||||
Colors.green,
|
||||
stat!.live!.downloadSpeed.humanReadable,
|
||||
),
|
||||
_buildCompactStat(
|
||||
Icons.upload,
|
||||
Colors.blue,
|
||||
stat!.live!.uploadSpeed.humanReadable,
|
||||
),
|
||||
if (stat!.live!.timeRemaining?.humanReadable != null)
|
||||
_buildCompactStat(
|
||||
Icons.timer,
|
||||
Colors.orange,
|
||||
stat!.live!.timeRemaining!.humanReadable,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Advanced Stats
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAdvancedStatRow(
|
||||
'Peers',
|
||||
'${stat!.live!.snapshot.peerStats.live}/${stat!.live!.snapshot.peerStats.seen}',
|
||||
),
|
||||
if (stat!.live!.averagePieceDownloadTime?.secs != null)
|
||||
_buildAdvancedStatRow(
|
||||
'Avg Download',
|
||||
'${stat!.live!.averagePieceDownloadTime?.secs}s',
|
||||
),
|
||||
_buildAdvancedStatRow(
|
||||
'Downloaded',
|
||||
_formatBytes(
|
||||
stat!.live!.snapshot.downloadedAndCheckedBytes),
|
||||
),
|
||||
_buildAdvancedStatRow(
|
||||
'Uploaded',
|
||||
_formatBytes(stat!.live!.snapshot.uploadedBytes),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactStat(IconData icon, Color color, String value) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdvancedStatRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TorrentStat {
|
||||
@JsonKey(name: "state")
|
||||
final String state;
|
||||
@JsonKey(name: "file_progress")
|
||||
final List<int> fileProgress;
|
||||
@JsonKey(name: "error")
|
||||
final dynamic error;
|
||||
@JsonKey(name: "progress_bytes")
|
||||
final int progressBytes;
|
||||
@JsonKey(name: "uploaded_bytes")
|
||||
final int uploadedBytes;
|
||||
@JsonKey(name: "total_bytes")
|
||||
final int totalBytes;
|
||||
@JsonKey(name: "finished")
|
||||
final bool finished;
|
||||
@JsonKey(name: "live")
|
||||
final Live? live;
|
||||
|
||||
TorrentStat({
|
||||
required this.state,
|
||||
required this.fileProgress,
|
||||
required this.error,
|
||||
required this.progressBytes,
|
||||
required this.uploadedBytes,
|
||||
required this.totalBytes,
|
||||
required this.finished,
|
||||
required this.live,
|
||||
});
|
||||
|
||||
TorrentStat copyWith({
|
||||
String? state,
|
||||
List<int>? fileProgress,
|
||||
dynamic error,
|
||||
int? progressBytes,
|
||||
int? uploadedBytes,
|
||||
int? totalBytes,
|
||||
bool? finished,
|
||||
Live? live,
|
||||
}) =>
|
||||
TorrentStat(
|
||||
state: state ?? this.state,
|
||||
fileProgress: fileProgress ?? this.fileProgress,
|
||||
error: error ?? this.error,
|
||||
progressBytes: progressBytes ?? this.progressBytes,
|
||||
uploadedBytes: uploadedBytes ?? this.uploadedBytes,
|
||||
totalBytes: totalBytes ?? this.totalBytes,
|
||||
finished: finished ?? this.finished,
|
||||
live: live ?? this.live,
|
||||
);
|
||||
|
||||
factory TorrentStat.fromJson(Map<String, dynamic> json) =>
|
||||
_$TorrentStatFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$TorrentStatToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Live {
|
||||
@JsonKey(name: "snapshot")
|
||||
final Snapshot snapshot;
|
||||
@JsonKey(name: "average_piece_download_time")
|
||||
final AveragePieceDownloadTime? averagePieceDownloadTime;
|
||||
@JsonKey(name: "download_speed")
|
||||
final LoadSpeed downloadSpeed;
|
||||
@JsonKey(name: "upload_speed")
|
||||
final LoadSpeed uploadSpeed;
|
||||
@JsonKey(name: "time_remaining")
|
||||
final TimeRemaining? timeRemaining;
|
||||
|
||||
Live({
|
||||
required this.snapshot,
|
||||
this.averagePieceDownloadTime,
|
||||
required this.downloadSpeed,
|
||||
required this.uploadSpeed,
|
||||
required this.timeRemaining,
|
||||
});
|
||||
|
||||
Live copyWith({
|
||||
Snapshot? snapshot,
|
||||
AveragePieceDownloadTime? averagePieceDownloadTime,
|
||||
LoadSpeed? downloadSpeed,
|
||||
LoadSpeed? uploadSpeed,
|
||||
TimeRemaining? timeRemaining,
|
||||
}) =>
|
||||
Live(
|
||||
snapshot: snapshot ?? this.snapshot,
|
||||
averagePieceDownloadTime:
|
||||
averagePieceDownloadTime ?? this.averagePieceDownloadTime,
|
||||
downloadSpeed: downloadSpeed ?? this.downloadSpeed,
|
||||
uploadSpeed: uploadSpeed ?? this.uploadSpeed,
|
||||
timeRemaining: timeRemaining ?? this.timeRemaining,
|
||||
);
|
||||
|
||||
factory Live.fromJson(Map<String, dynamic> json) => _$LiveFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$LiveToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class AveragePieceDownloadTime {
|
||||
@JsonKey(name: "secs")
|
||||
final int secs;
|
||||
@JsonKey(name: "nanos")
|
||||
final int nanos;
|
||||
|
||||
AveragePieceDownloadTime({
|
||||
required this.secs,
|
||||
required this.nanos,
|
||||
});
|
||||
|
||||
AveragePieceDownloadTime copyWith({
|
||||
int? secs,
|
||||
int? nanos,
|
||||
}) =>
|
||||
AveragePieceDownloadTime(
|
||||
secs: secs ?? this.secs,
|
||||
nanos: nanos ?? this.nanos,
|
||||
);
|
||||
|
||||
factory AveragePieceDownloadTime.fromJson(Map<String, dynamic> json) =>
|
||||
_$AveragePieceDownloadTimeFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$AveragePieceDownloadTimeToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class LoadSpeed {
|
||||
@JsonKey(name: "mbps")
|
||||
final double mbps;
|
||||
@JsonKey(name: "human_readable")
|
||||
final String humanReadable;
|
||||
|
||||
LoadSpeed({
|
||||
required this.mbps,
|
||||
required this.humanReadable,
|
||||
});
|
||||
|
||||
LoadSpeed copyWith({
|
||||
double? mbps,
|
||||
String? humanReadable,
|
||||
}) =>
|
||||
LoadSpeed(
|
||||
mbps: mbps ?? this.mbps,
|
||||
humanReadable: humanReadable ?? this.humanReadable,
|
||||
);
|
||||
|
||||
factory LoadSpeed.fromJson(Map<String, dynamic> json) =>
|
||||
_$LoadSpeedFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$LoadSpeedToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Snapshot {
|
||||
@JsonKey(name: "downloaded_and_checked_bytes")
|
||||
final int downloadedAndCheckedBytes;
|
||||
@JsonKey(name: "fetched_bytes")
|
||||
final int fetchedBytes;
|
||||
@JsonKey(name: "uploaded_bytes")
|
||||
final int uploadedBytes;
|
||||
@JsonKey(name: "downloaded_and_checked_pieces")
|
||||
final int downloadedAndCheckedPieces;
|
||||
@JsonKey(name: "total_piece_download_ms")
|
||||
final int totalPieceDownloadMs;
|
||||
@JsonKey(name: "peer_stats")
|
||||
final PeerStats peerStats;
|
||||
|
||||
Snapshot({
|
||||
required this.downloadedAndCheckedBytes,
|
||||
required this.fetchedBytes,
|
||||
required this.uploadedBytes,
|
||||
required this.downloadedAndCheckedPieces,
|
||||
required this.totalPieceDownloadMs,
|
||||
required this.peerStats,
|
||||
});
|
||||
|
||||
Snapshot copyWith({
|
||||
int? downloadedAndCheckedBytes,
|
||||
int? fetchedBytes,
|
||||
int? uploadedBytes,
|
||||
int? downloadedAndCheckedPieces,
|
||||
int? totalPieceDownloadMs,
|
||||
PeerStats? peerStats,
|
||||
}) =>
|
||||
Snapshot(
|
||||
downloadedAndCheckedBytes:
|
||||
downloadedAndCheckedBytes ?? this.downloadedAndCheckedBytes,
|
||||
fetchedBytes: fetchedBytes ?? this.fetchedBytes,
|
||||
uploadedBytes: uploadedBytes ?? this.uploadedBytes,
|
||||
downloadedAndCheckedPieces:
|
||||
downloadedAndCheckedPieces ?? this.downloadedAndCheckedPieces,
|
||||
totalPieceDownloadMs: totalPieceDownloadMs ?? this.totalPieceDownloadMs,
|
||||
peerStats: peerStats ?? this.peerStats,
|
||||
);
|
||||
|
||||
factory Snapshot.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnapshotFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SnapshotToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class PeerStats {
|
||||
@JsonKey(name: "queued")
|
||||
final int queued;
|
||||
@JsonKey(name: "connecting")
|
||||
final int connecting;
|
||||
@JsonKey(name: "live")
|
||||
final int live;
|
||||
@JsonKey(name: "seen")
|
||||
final int seen;
|
||||
@JsonKey(name: "dead")
|
||||
final int dead;
|
||||
@JsonKey(name: "not_needed")
|
||||
final int notNeeded;
|
||||
@JsonKey(name: "steals")
|
||||
final int steals;
|
||||
|
||||
PeerStats({
|
||||
required this.queued,
|
||||
required this.connecting,
|
||||
required this.live,
|
||||
required this.seen,
|
||||
required this.dead,
|
||||
required this.notNeeded,
|
||||
required this.steals,
|
||||
});
|
||||
|
||||
PeerStats copyWith({
|
||||
int? queued,
|
||||
int? connecting,
|
||||
int? live,
|
||||
int? seen,
|
||||
int? dead,
|
||||
int? notNeeded,
|
||||
int? steals,
|
||||
}) =>
|
||||
PeerStats(
|
||||
queued: queued ?? this.queued,
|
||||
connecting: connecting ?? this.connecting,
|
||||
live: live ?? this.live,
|
||||
seen: seen ?? this.seen,
|
||||
dead: dead ?? this.dead,
|
||||
notNeeded: notNeeded ?? this.notNeeded,
|
||||
steals: steals ?? this.steals,
|
||||
);
|
||||
|
||||
factory PeerStats.fromJson(Map<String, dynamic> json) =>
|
||||
_$PeerStatsFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PeerStatsToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TimeRemaining {
|
||||
@JsonKey(name: "duration")
|
||||
final AveragePieceDownloadTime duration;
|
||||
@JsonKey(name: "human_readable")
|
||||
final String humanReadable;
|
||||
|
||||
TimeRemaining({
|
||||
required this.duration,
|
||||
required this.humanReadable,
|
||||
});
|
||||
|
||||
TimeRemaining copyWith({
|
||||
AveragePieceDownloadTime? duration,
|
||||
String? humanReadable,
|
||||
}) =>
|
||||
TimeRemaining(
|
||||
duration: duration ?? this.duration,
|
||||
humanReadable: humanReadable ?? this.humanReadable,
|
||||
);
|
||||
|
||||
factory TimeRemaining.fromJson(Map<String, dynamic> json) =>
|
||||
_$TimeRemainingFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$TimeRemainingToJson(this);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
class TraktIntegrationVideo {
|
||||
Player player;
|
||||
|
||||
TraktIntegrationVideo({
|
||||
required this.player,
|
||||
});
|
||||
|
||||
initState() {}
|
||||
|
||||
dispose() {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,276 +0,0 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:madari_client/utils/load_language.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart' as types;
|
||||
|
||||
class VideoViewerMobile extends StatefulWidget {
|
||||
final VoidCallback onSubtitleSelect;
|
||||
final VoidCallback onLibrarySelect;
|
||||
final Player player;
|
||||
final DocSource source;
|
||||
final VideoController controller;
|
||||
final VoidCallback onAudioSelect;
|
||||
final PlaybackConfig config;
|
||||
final GlobalKey<VideoState> videoKey;
|
||||
final LibraryItem? meta;
|
||||
final Future<void> Function(int index) onVideoChange;
|
||||
|
||||
const VideoViewerMobile({
|
||||
super.key,
|
||||
required this.onLibrarySelect,
|
||||
required this.onSubtitleSelect,
|
||||
required this.player,
|
||||
required this.source,
|
||||
required this.controller,
|
||||
required this.onAudioSelect,
|
||||
required this.config,
|
||||
required this.videoKey,
|
||||
required this.meta,
|
||||
required this.onVideoChange,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoViewerMobile> createState() => _VideoViewerMobileState();
|
||||
}
|
||||
|
||||
class _VideoViewerMobileState extends State<VideoViewerMobile> {
|
||||
final Logger _logger = Logger('_VideoViewerMobileState');
|
||||
bool isScaled = false;
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
final mobile = _getMobileControls(
|
||||
context,
|
||||
onLibrarySelect: widget.onLibrarySelect,
|
||||
player: widget.player,
|
||||
source: widget.source,
|
||||
onSubtitleClick: widget.onSubtitleSelect,
|
||||
onAudioClick: widget.onAudioSelect,
|
||||
toggleScale: () {
|
||||
setState(() {
|
||||
isScaled = !isScaled;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
String subtitleStyleName = widget.config.subtitleStyle ?? 'Normal';
|
||||
String subtitleStyleColor = widget.config.subtitleColor ?? 'white';
|
||||
double subtitleSize = widget.config.subtitleSize;
|
||||
|
||||
Color hexToColor(String hexColor) {
|
||||
final hexCode = hexColor.replaceAll('#', '');
|
||||
try {
|
||||
return Color(int.parse('0x$hexCode'));
|
||||
} catch (e) {
|
||||
return Colors.white;
|
||||
}
|
||||
}
|
||||
|
||||
FontStyle getFontStyleFromString(String styleName) {
|
||||
switch (styleName.toLowerCase()) {
|
||||
case 'italic':
|
||||
return FontStyle.italic;
|
||||
case 'normal':
|
||||
default:
|
||||
return FontStyle.normal;
|
||||
}
|
||||
}
|
||||
|
||||
FontStyle currentFontStyle = getFontStyleFromString(subtitleStyleName);
|
||||
return MaterialVideoControlsTheme(
|
||||
fullscreen: mobile,
|
||||
normal: mobile,
|
||||
child: Video(
|
||||
subtitleViewConfiguration: SubtitleViewConfiguration(
|
||||
style: TextStyle(
|
||||
color: hexToColor(subtitleStyleColor),
|
||||
fontSize: subtitleSize,
|
||||
fontStyle: currentFontStyle,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight,
|
||||
pauseUponEnteringBackgroundMode: true,
|
||||
key: widget.videoKey,
|
||||
onExitFullscreen: () async {
|
||||
await defaultExitNativeFullscreen();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
controller: widget.controller,
|
||||
controls: MaterialVideoControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_getMobileControls(
|
||||
BuildContext context, {
|
||||
required DocSource source,
|
||||
required Player player,
|
||||
required VoidCallback onSubtitleClick,
|
||||
required VoidCallback onAudioClick,
|
||||
required VoidCallback toggleScale,
|
||||
required VoidCallback onLibrarySelect,
|
||||
}) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final meta = widget.meta;
|
||||
|
||||
return MaterialVideoControlsThemeData(
|
||||
topButtonBar: [
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop();
|
||||
Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.arrow_back,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
meta.toString(),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
if (meta is types.Meta)
|
||||
if (meta.type == "series")
|
||||
SeasonSource(
|
||||
meta: meta,
|
||||
isMobile: true,
|
||||
player: player,
|
||||
onVideoChange: (index) async {
|
||||
await widget.onVideoChange(index);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
bufferingIndicatorBuilder: (source is TorrentSource)
|
||||
? (ctx) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: TorrentStats(
|
||||
torrentHash: (source).infoHash,
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
brightnessGesture: true,
|
||||
seekGesture: true,
|
||||
seekOnDoubleTap: true,
|
||||
gesturesEnabledWhileControlsVisible: true,
|
||||
shiftSubtitlesOnControlsVisibilityChange: true,
|
||||
seekBarMargin: const EdgeInsets.only(bottom: 54),
|
||||
speedUpOnLongPress: true,
|
||||
speedUpFactor: 2,
|
||||
volumeGesture: true,
|
||||
bottomButtonBar: [
|
||||
const MaterialPlayOrPauseButton(),
|
||||
const MaterialPositionIndicator(),
|
||||
const Spacer(),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
final speeds = [
|
||||
0.5,
|
||||
0.75,
|
||||
1.0,
|
||||
1.25,
|
||||
1.5,
|
||||
1.75,
|
||||
2.0,
|
||||
2.25,
|
||||
2.5,
|
||||
3.0,
|
||||
3.25,
|
||||
3.5,
|
||||
3.75,
|
||||
4.0,
|
||||
4.25,
|
||||
4.5,
|
||||
4.75,
|
||||
5.0
|
||||
];
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => Card(
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Playback Speed',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: speeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final speed = speeds[index];
|
||||
return ListTile(
|
||||
title: Text('${speed}x'),
|
||||
selected: player.state.rate == speed,
|
||||
onTap: () {
|
||||
player.setRate(speed);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.speed),
|
||||
),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
onSubtitleClick();
|
||||
},
|
||||
icon: const Icon(Icons.subtitles),
|
||||
),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
onAudioClick();
|
||||
},
|
||||
icon: const Icon(Icons.audio_file),
|
||||
),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
toggleScale();
|
||||
},
|
||||
icon: const Icon(Icons.fit_screen_outlined),
|
||||
),
|
||||
],
|
||||
topButtonBarMargin: EdgeInsets.only(
|
||||
top: mediaQuery.padding.top,
|
||||
),
|
||||
bottomButtonBarMargin: EdgeInsets.only(
|
||||
bottom: mediaQuery.viewInsets.bottom,
|
||||
left: 4.0,
|
||||
right: 4.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/audio_track_selector.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/subtitle_selector.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/tv_controls.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:madari_client/utils/load_language.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../../../utils/tv_detector.dart';
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart' as types;
|
||||
import '../../../connections/widget/base/render_stream_list.dart';
|
||||
import 'desktop_video_player.dart';
|
||||
|
||||
class VideoViewerUi extends StatefulWidget {
|
||||
final VideoController controller;
|
||||
final Player player;
|
||||
final PlaybackConfig config;
|
||||
final DocSource source;
|
||||
final VoidCallback onLibrarySelect;
|
||||
final BaseConnectionService? service;
|
||||
final LibraryItem? meta;
|
||||
final Function(
|
||||
DocSource source,
|
||||
LibraryItem item,
|
||||
) onSourceChange;
|
||||
|
||||
const VideoViewerUi({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.player,
|
||||
required this.config,
|
||||
required this.source,
|
||||
required this.onLibrarySelect,
|
||||
required this.service,
|
||||
this.meta,
|
||||
required this.onSourceChange,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoViewerUi> createState() => _VideoViewerUiState();
|
||||
}
|
||||
|
||||
class _VideoViewerUiState extends State<VideoViewerUi> {
|
||||
late final GlobalKey<VideoState> key = GlobalKey<VideoState>();
|
||||
final Logger _logger = Logger('_VideoViewerUiState');
|
||||
|
||||
final List<StreamSubscription> listeners = [];
|
||||
|
||||
bool defaultConfigSelected = false;
|
||||
|
||||
bool subtitleSelectionHandled = false;
|
||||
bool audioSelectionHandled = false;
|
||||
|
||||
void setDefaultAudioTracks(Tracks tracks) {
|
||||
if (defaultConfigSelected == true &&
|
||||
(tracks.audio.length <= 1 || tracks.audio.length <= 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
defaultConfigSelected = true;
|
||||
|
||||
widget.controller.player.setRate(widget.config.playbackSpeed);
|
||||
|
||||
final defaultSubtitle = widget.config.defaultSubtitleTrack;
|
||||
final defaultAudio = widget.config.defaultAudioTrack;
|
||||
|
||||
for (final item in tracks.audio) {
|
||||
if ((defaultAudio == item.id ||
|
||||
defaultAudio == item.language ||
|
||||
defaultAudio == item.title) &&
|
||||
audioSelectionHandled == false) {
|
||||
widget.controller.player.setAudioTrack(item);
|
||||
audioSelectionHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.config.disableSubtitle) {
|
||||
for (final item in tracks.subtitle) {
|
||||
if ((item.id == "no" || item.language == "no" || item.title == "no") &&
|
||||
subtitleSelectionHandled == false) {
|
||||
widget.controller.player.setSubtitleTrack(item);
|
||||
subtitleSelectionHandled = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (final item in tracks.subtitle) {
|
||||
if ((defaultSubtitle == item.id ||
|
||||
defaultSubtitle == item.language ||
|
||||
defaultSubtitle == item.title) &&
|
||||
subtitleSelectionHandled == false) {
|
||||
subtitleSelectionHandled = true;
|
||||
widget.controller.player.setSubtitleTrack(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final listenerComplete = widget.player.stream.completed.listen((completed) {
|
||||
if (completed) {
|
||||
widget.onLibrarySelect();
|
||||
key.currentState?.exitFullscreen();
|
||||
}
|
||||
});
|
||||
|
||||
listeners.add(listenerComplete);
|
||||
|
||||
if (!kIsWeb) {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
key.currentState?.enterFullscreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final listener = widget.player.stream.tracks.listen((tracks) {
|
||||
if (mounted) {
|
||||
setDefaultAudioTracks(tracks);
|
||||
}
|
||||
});
|
||||
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
print(widget.meta.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
for (final listener in listeners) {
|
||||
listener.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _buildBody(context);
|
||||
}
|
||||
|
||||
_buildBody(BuildContext context) {
|
||||
if (DeviceDetector.isTV()) {
|
||||
return MaterialTvVideoControlsTheme(
|
||||
fullscreen: const MaterialTvVideoControlsThemeData(),
|
||||
normal: const MaterialTvVideoControlsThemeData(),
|
||||
child: Video(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
fit: BoxFit.fitWidth,
|
||||
controller: widget.controller,
|
||||
controls: MaterialTvVideoControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
switch (Theme.of(context).platform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.iOS:
|
||||
return VideoViewerMobile(
|
||||
onLibrarySelect: widget.onLibrarySelect,
|
||||
onSubtitleSelect: onSubtitleSelect,
|
||||
player: widget.player,
|
||||
source: widget.source,
|
||||
controller: widget.controller,
|
||||
onAudioSelect: onAudioSelect,
|
||||
config: widget.config,
|
||||
videoKey: key,
|
||||
meta: widget.meta,
|
||||
onVideoChange: (index) async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
widget.player.pause();
|
||||
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height,
|
||||
),
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: RenderStreamList(
|
||||
service: widget.service!,
|
||||
id: (widget.meta as types.Meta).copyWith(
|
||||
selectedVideoIndex: index,
|
||||
),
|
||||
shouldPop: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
widget.onSourceChange(
|
||||
result,
|
||||
(widget.meta as types.Meta).copyWith(
|
||||
selectedVideoIndex: index,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
default:
|
||||
return _buildDesktop(context);
|
||||
}
|
||||
}
|
||||
|
||||
_buildDesktop(BuildContext context) {
|
||||
final desktop = getDesktopControls(
|
||||
context,
|
||||
player: widget.player,
|
||||
source: widget.source,
|
||||
onAudioSelect: onAudioSelect,
|
||||
onSubtitleSelect: onSubtitleSelect,
|
||||
meta: widget.meta,
|
||||
onVideoChange: (index) async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
widget.player.pause();
|
||||
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height,
|
||||
),
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: RenderStreamList(
|
||||
service: widget.service!,
|
||||
id: (widget.meta as types.Meta).copyWith(
|
||||
selectedVideoIndex: index,
|
||||
),
|
||||
shouldPop: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
widget.onSourceChange(
|
||||
result,
|
||||
(widget.meta as types.Meta).copyWith(
|
||||
selectedVideoIndex: index,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return MaterialDesktopVideoControlsTheme(
|
||||
normal: desktop,
|
||||
fullscreen: desktop,
|
||||
child: Video(
|
||||
key: key,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
fit: BoxFit.fitWidth,
|
||||
controller: widget.controller,
|
||||
controls: MaterialDesktopVideoControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
onAudioSelect() {
|
||||
_logger.info('Audio track selection triggered.');
|
||||
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => AudioTrackSelector(
|
||||
player: widget.player,
|
||||
config: widget.config,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
onSubtitleSelect() {
|
||||
_logger.info('Subtitle selection triggered.');
|
||||
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => SubtitleSelector(
|
||||
player: widget.player,
|
||||
config: widget.config,
|
||||
service: widget.service,
|
||||
meta: widget.meta,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../utils/get_types.dart';
|
||||
|
||||
part 'doc_source.g.dart';
|
||||
|
||||
enum DocType { pdf, video, audio, photo, unknown }
|
||||
|
||||
sealed class DocSource {
|
||||
String title;
|
||||
String id;
|
||||
String? season;
|
||||
String? episode;
|
||||
|
||||
DocSource({
|
||||
required this.title,
|
||||
required this.id,
|
||||
this.season,
|
||||
this.episode,
|
||||
});
|
||||
|
||||
DocType getType();
|
||||
|
||||
Future<void> init() async {}
|
||||
|
||||
void dispose();
|
||||
}
|
||||
|
||||
class IframeSource extends DocSource {
|
||||
late final String url;
|
||||
|
||||
IframeSource({
|
||||
required this.url,
|
||||
required super.title,
|
||||
required super.id,
|
||||
super.season,
|
||||
super.episode,
|
||||
});
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
|
||||
@override
|
||||
DocType getType() {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class ProgressStatus extends DocSource {
|
||||
final double? percentage;
|
||||
final String? progressText;
|
||||
|
||||
@override
|
||||
DocType getType() {
|
||||
return DocType.unknown;
|
||||
}
|
||||
|
||||
ProgressStatus({
|
||||
required super.id,
|
||||
required super.title,
|
||||
this.progressText,
|
||||
this.percentage,
|
||||
});
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
class URLSource extends DocSource {
|
||||
String url;
|
||||
String? fileName;
|
||||
Map<String, String> headers = {};
|
||||
|
||||
URLSource({
|
||||
required super.title,
|
||||
required this.url,
|
||||
required super.id,
|
||||
super.season,
|
||||
super.episode,
|
||||
this.fileName,
|
||||
this.headers = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
DocType getType() {
|
||||
String cleanUrl = (url).split('?').first;
|
||||
String extension = (fileName ?? cleanUrl).split('.').last.toLowerCase();
|
||||
|
||||
return getTypeFromExtension(extension.trim());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
class MediaURLSource extends URLSource {
|
||||
MediaURLSource({required super.title, required super.url, required super.id});
|
||||
|
||||
@override
|
||||
DocType getType() {
|
||||
return DocType.video;
|
||||
}
|
||||
}
|
||||
|
||||
class TorrentSource extends URLSource {
|
||||
final String infoHash;
|
||||
@override
|
||||
final String fileName;
|
||||
final List<String>? trackers;
|
||||
bool disposed = false;
|
||||
|
||||
TorrentSource({
|
||||
required super.id,
|
||||
required super.title,
|
||||
required this.infoHash,
|
||||
required this.fileName,
|
||||
super.season,
|
||||
super.episode,
|
||||
this.trackers,
|
||||
super.url = "",
|
||||
});
|
||||
|
||||
@override
|
||||
DocType getType() {
|
||||
String extension = fileName.split('.').last.toLowerCase();
|
||||
|
||||
return getTypeFromExtension(extension);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
final trackers = [
|
||||
"udp://47.ip-51-68-199.eu:6969/announce",
|
||||
"udp://9.rarbg.me:2940",
|
||||
"udp://9.rarbg.to:2820",
|
||||
"udp://exodus.desync.com:6969/announce",
|
||||
"udp://explodie.org:6969/announce",
|
||||
"udp://ipv4.tracker.harry.lu:80/announce",
|
||||
"udp://open.stealth.si:80/announce",
|
||||
"udp://opentor.org:2710/announce",
|
||||
"udp://opentracker.i2p.rocks:6969/announce",
|
||||
"udp://retracker.lanta-net.ru:2710/announce",
|
||||
"udp://tracker.cyberia.is:6969/announce",
|
||||
"udp://tracker.dler.org:6969/announce",
|
||||
"udp://tracker.ds.is:6969/announce",
|
||||
"udp://tracker.internetwarriors.net:1337",
|
||||
"udp://tracker.openbittorrent.com:6969/announce",
|
||||
"udp://tracker.opentrackr.org:1337/announce",
|
||||
"udp://tracker.tiny-vps.com:6969/announce",
|
||||
"udp://tracker.torrent.eu.org:451/announce",
|
||||
"udp://valakas.rollo.dnsabr.com:2710/announce",
|
||||
"udp://www.torrent.eu.org:451/announce"
|
||||
];
|
||||
|
||||
final value1 =
|
||||
await http.get(Uri.parse("http://localhost:64544/torrents/$infoHash"));
|
||||
|
||||
if (jsonDecode(value1.body)["error_kind"] == "torrent_not_found") {
|
||||
await http.post(
|
||||
Uri.parse("http://localhost:64544/torrents?overwrite=true"),
|
||||
body: addTrackersToMagnet(
|
||||
"magnet:?xt=urn:btih:${Uri.encodeComponent(infoHash)}",
|
||||
trackers,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await http.post(
|
||||
Uri.parse("http://localhost:64544/torrents/$infoHash/start"),
|
||||
);
|
||||
}
|
||||
|
||||
final value = await http.get(
|
||||
Uri.parse("http://localhost:64544/torrents/$infoHash"),
|
||||
);
|
||||
|
||||
final obj = jsonDecode(
|
||||
value.body,
|
||||
);
|
||||
|
||||
final objTorrent = TorrentInfoObject.fromJson(obj);
|
||||
|
||||
for (final (index, file) in objTorrent.files.indexed) {
|
||||
if (path.basename(file.name) == fileName) {
|
||||
url = "http://localhost:64544/torrents/$infoHash/stream/$index";
|
||||
|
||||
await http.post(
|
||||
Uri.parse(
|
||||
"http://localhost:64544/torrents/$infoHash/update_only_files"),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: jsonEncode(
|
||||
{
|
||||
"only_files": [index]
|
||||
},
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (url == "") throw AssertionError();
|
||||
|
||||
return super.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
disposed = true;
|
||||
|
||||
http
|
||||
.post(
|
||||
Uri.parse(
|
||||
"http://localhost:64544/torrents/$infoHash/pause",
|
||||
),
|
||||
)
|
||||
.then(
|
||||
(docs) {
|
||||
if (kDebugMode) {
|
||||
print(docs.statusCode);
|
||||
print("Stopped downloading file");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> readFirst1MBFromUrl(String url) async {
|
||||
final client = http.Client();
|
||||
|
||||
try {
|
||||
int attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
if (kDebugMode) {
|
||||
print("Reading $attempts at $url");
|
||||
}
|
||||
|
||||
if (disposed) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
final request = http.Request('GET', Uri.parse(url));
|
||||
request.headers['range'] = 'bytes=0-${1024 * 1}';
|
||||
|
||||
final streamedResponse = await client.send(request);
|
||||
|
||||
// Check if the response is successful
|
||||
if (streamedResponse.statusCode >= 200 &&
|
||||
streamedResponse.statusCode < 300) {
|
||||
final bytes =
|
||||
await streamedResponse.stream.take(1024 * 1024).fold<List<int>>(
|
||||
[],
|
||||
(previous, element) => previous..addAll(element),
|
||||
);
|
||||
return Uint8List.fromList(bytes);
|
||||
}
|
||||
|
||||
throw HttpException(
|
||||
'Failed with status: ${streamedResponse.statusCode}');
|
||||
} catch (e) {
|
||||
attempts++;
|
||||
if (attempts >= maxAttempts) {
|
||||
throw Exception('Failed after $maxAttempts attempts: $e');
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
}
|
||||
|
||||
await Future.delayed(
|
||||
Duration(milliseconds: pow(2, attempts).toInt() * 100),
|
||||
);
|
||||
}
|
||||
}
|
||||
throw Exception('Unexpected error');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TorrentInfoObject {
|
||||
final List<TorrentFile> files;
|
||||
|
||||
TorrentInfoObject({
|
||||
required this.files,
|
||||
});
|
||||
|
||||
factory TorrentInfoObject.fromJson(Map<String, dynamic> json) =>
|
||||
_$TorrentInfoObjectFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$TorrentInfoObjectToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TorrentFile {
|
||||
final String name;
|
||||
|
||||
TorrentFile({
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory TorrentFile.fromJson(Map<String, dynamic> json) =>
|
||||
_$TorrentFileFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$TorrentFileToJson(this);
|
||||
}
|
||||
|
||||
String escapeRegex(String input) {
|
||||
const specialChars = r'[.*+?^${}()|[\]\\]';
|
||||
|
||||
return input.replaceAllMapped(
|
||||
RegExp(specialChars), (Match match) => '\\${match.group(0)}');
|
||||
}
|
||||
|
||||
class FileSource extends DocSource {
|
||||
String filePath;
|
||||
|
||||
FileSource({
|
||||
required super.title,
|
||||
required this.filePath,
|
||||
required super.id,
|
||||
});
|
||||
|
||||
@override
|
||||
DocType getType() {
|
||||
String extension = filePath.split('.').last.toLowerCase();
|
||||
return getTypeFromExtension(extension);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
String addTrackersToMagnet(String magnetLink, List<String> trackers) {
|
||||
final uri = Uri.parse(magnetLink);
|
||||
|
||||
if (!uri.scheme.contains("magnet")) {
|
||||
throw ArgumentError("Invalid magnet link");
|
||||
}
|
||||
|
||||
final existingTrackers = uri.queryParametersAll['tr'] ?? [];
|
||||
final updatedTrackers = [...existingTrackers, ...trackers];
|
||||
|
||||
final updatedQueryParameters =
|
||||
Map<String, List<String>>.from(uri.queryParametersAll)
|
||||
..['tr'] = updatedTrackers;
|
||||
|
||||
final updatedUri = uri.replace(queryParameters: updatedQueryParameters);
|
||||
|
||||
return updatedUri.toString();
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import '../types/doc_source.dart';
|
||||
|
||||
DocType getTypeFromExtension(String extension) {
|
||||
switch (extension) {
|
||||
// PDF extensions
|
||||
case 'pdf':
|
||||
return DocType.pdf;
|
||||
|
||||
// Video extensions
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
case 'wmv':
|
||||
case 'mkv':
|
||||
case 'webm':
|
||||
case 'flv':
|
||||
case 'm4v':
|
||||
case 'mpg':
|
||||
case 'mpeg':
|
||||
case '3gp':
|
||||
return DocType.video;
|
||||
|
||||
// Audio extensions
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'flac':
|
||||
case 'aac':
|
||||
case 'm4a':
|
||||
case 'wma':
|
||||
case 'ogg':
|
||||
case 'opus':
|
||||
return DocType.audio;
|
||||
|
||||
// Photo extensions
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'bmp':
|
||||
case 'tiff':
|
||||
case 'webp':
|
||||
case 'svg':
|
||||
case 'heic':
|
||||
case 'raw':
|
||||
return DocType.photo;
|
||||
|
||||
default:
|
||||
return DocType.unknown;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class DownloadDialog extends StatefulWidget {
|
||||
const DownloadDialog({super.key});
|
||||
|
||||
@override
|
||||
State<DownloadDialog> createState() => _DownloadDialogState();
|
||||
}
|
||||
|
||||
class _DownloadDialogState extends State<DownloadDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _urlController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
bool _isValidating = false;
|
||||
String? _validationError;
|
||||
Map<String, dynamic>? _fileInfo;
|
||||
|
||||
Future<void> _startDownload() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final task = DownloadTask(
|
||||
url: _urlController.text,
|
||||
filename: '${_nameController.text}.mp4',
|
||||
directory: 'downloads',
|
||||
updates: Updates.statusAndProgress,
|
||||
allowPause: true,
|
||||
displayName: _nameController.text,
|
||||
);
|
||||
|
||||
await FileDownloader().enqueue(task);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _validateUrl() async {
|
||||
if (_urlController.text.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isValidating = true;
|
||||
_validationError = null;
|
||||
_fileInfo = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(_urlController.text);
|
||||
if (!await canLaunchUrl(uri)) {
|
||||
throw Exception('Invalid URL');
|
||||
}
|
||||
|
||||
// Make a HEAD request to get file information
|
||||
final response = await http.head(uri);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Could not access file');
|
||||
}
|
||||
|
||||
// Get file size from headers
|
||||
final contentLength = response.headers['content-length'];
|
||||
final fileSize = contentLength != null
|
||||
? _formatFileSize(int.parse(contentLength))
|
||||
: 'Unknown size';
|
||||
|
||||
// Get content type from headers
|
||||
final contentType = response.headers['content-type'] ?? 'Unknown type';
|
||||
|
||||
// Extract filename from URL or Content-Disposition header
|
||||
String fileName = '';
|
||||
final disposition = response.headers['content-disposition'];
|
||||
if (disposition != null && disposition.contains('filename=')) {
|
||||
fileName = disposition.split('filename=')[1].replaceAll('"', '');
|
||||
} else {
|
||||
fileName = path.basename(uri.path);
|
||||
}
|
||||
|
||||
// Remove extension from filename for display name
|
||||
_nameController.text = path.basenameWithoutExtension(fileName);
|
||||
|
||||
_fileInfo = {
|
||||
'size': fileSize,
|
||||
'type': contentType,
|
||||
'filename': fileName,
|
||||
};
|
||||
} catch (e) {
|
||||
_validationError = 'Invalid URL: ${e.toString()}';
|
||||
} finally {
|
||||
setState(() {
|
||||
_isValidating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _formatFileSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width < 600 ? double.infinity : 500,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download_rounded,
|
||||
size: 30,
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text(
|
||||
'Add New Download',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// URL Input Field
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'URL',
|
||||
hintText: 'Paste your download URL here',
|
||||
filled: true,
|
||||
prefixIcon: const Icon(Icons.link, color: Colors.grey),
|
||||
suffixIcon: _isValidating
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.red),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.check_circle_outline,
|
||||
color: Colors.white),
|
||||
onPressed: _validateUrl,
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
keyboardType: TextInputType.url,
|
||||
onChanged: (_) => setState(() => _fileInfo = null),
|
||||
),
|
||||
|
||||
// Validation Error
|
||||
if (_validationError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_validationError!,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
// File Information
|
||||
if (_fileInfo != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[850],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[700]!),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_infoRow(Icons.folder_outlined, 'File Size:',
|
||||
_fileInfo?['size'] ?? ''),
|
||||
const SizedBox(height: 12),
|
||||
_infoRow(Icons.description_outlined, 'Type:',
|
||||
_fileInfo?['type'] ?? ''),
|
||||
const SizedBox(height: 12),
|
||||
_infoRow(Icons.insert_drive_file_outlined, 'File:',
|
||||
_fileInfo?['filename'] ?? ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Display Name Input
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Display Name',
|
||||
hintText: 'Enter a name for this download',
|
||||
filled: true,
|
||||
prefixIcon: Icon(Icons.edit, color: Colors.grey),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a name';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// Action Buttons
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12),
|
||||
),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: Colors.grey[400]),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _fileInfo != null ? _startDownload : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
disabledBackgroundColor: Colors.grey[700],
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.download_rounded, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Download',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(IconData icon, String label, String value) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: Colors.grey[400], size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: Colors.grey[400]),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
192
lib/features/downloads/pages/downloads_page.dart
Normal file
192
lib/features/downloads/pages/downloads_page.dart
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../service/download_service.dart';
|
||||
|
||||
class DownloadsPage extends StatefulWidget {
|
||||
const DownloadsPage({super.key});
|
||||
|
||||
@override
|
||||
State<DownloadsPage> createState() => _DownloadsPageState();
|
||||
}
|
||||
|
||||
class _DownloadsPageState extends State<DownloadsPage> {
|
||||
final _urlController = TextEditingController();
|
||||
final _filenameController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
_filenameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startDownload() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
try {
|
||||
await DownloadService.instance.startDownload(
|
||||
_urlController.text,
|
||||
_filenameController.text,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
_urlController.clear();
|
||||
_filenameController.clear();
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Downloads"),
|
||||
),
|
||||
body: Center(
|
||||
child: const Text("Not implemented") ??
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true)
|
||||
return 'URL is required';
|
||||
if (!(Uri.tryParse(value!)?.hasAbsolutePath ??
|
||||
true)) {
|
||||
return 'Invalid URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _filenameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Filename',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true)
|
||||
return 'Filename is required';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: _isSubmitting ? null : _startDownload,
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: const Text('Start Download'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
StreamBuilder<List<TaskRecord>>(
|
||||
stream: DownloadService.instance.records,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final records = snapshot.data!;
|
||||
if (records.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No downloads yet',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: records.length,
|
||||
itemBuilder: (context, index) {
|
||||
final record = records[index];
|
||||
final progress =
|
||||
(record.progress * 100).toStringAsFixed(1);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(record.task.filename),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (record.status == TaskStatus.running)
|
||||
LinearProgressIndicator(
|
||||
value: record.progress),
|
||||
const SizedBox(height: 4),
|
||||
Text('Status: ${record.status.name}'),
|
||||
if (record.status == TaskStatus.running)
|
||||
Text('Progress: $progress%'),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (record.status == TaskStatus.running) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.pause),
|
||||
onPressed: () => DownloadService.instance
|
||||
.pauseDownload(record.taskId),
|
||||
),
|
||||
] else if (record.status ==
|
||||
TaskStatus.paused) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
onPressed: () => DownloadService.instance
|
||||
.resumeDownload(record.taskId),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => DownloadService.instance
|
||||
.cancelDownload(record.taskId),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
82
lib/features/downloads/service/download_service.dart
Normal file
82
lib/features/downloads/service/download_service.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
|
||||
class DownloadService {
|
||||
static final DownloadService _instance = DownloadService._internal();
|
||||
static DownloadService get instance => _instance;
|
||||
|
||||
final StreamController<List<TaskRecord>> _recordsController =
|
||||
StreamController<List<TaskRecord>>.broadcast();
|
||||
Stream<List<TaskRecord>> get records => _recordsController.stream;
|
||||
|
||||
final Map<String, DownloadTask> _tasks = {};
|
||||
|
||||
DownloadService._internal() {
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
await FileDownloader().trackTasks();
|
||||
|
||||
FileDownloader().configureNotification(
|
||||
running: const TaskNotification('Downloading', 'File: {filename}'),
|
||||
complete: const TaskNotification('Download Complete', 'File: {filename}'),
|
||||
error: const TaskNotification('Download Failed', 'File: {filename}'),
|
||||
progressBar: true,
|
||||
);
|
||||
|
||||
FileDownloader().updates.listen((update) async {
|
||||
await _updateRecords();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateRecords() async {
|
||||
final records = await FileDownloader().database.allRecords();
|
||||
_recordsController.add(records);
|
||||
}
|
||||
|
||||
Future<bool> startDownload(String url, String filename) async {
|
||||
final task = DownloadTask(
|
||||
url: url,
|
||||
filename: filename,
|
||||
updates: Updates.statusAndProgress,
|
||||
allowPause: true,
|
||||
retries: 3,
|
||||
);
|
||||
|
||||
_tasks[task.taskId] = task;
|
||||
final success = await FileDownloader().enqueue(task);
|
||||
await _updateRecords();
|
||||
return success;
|
||||
}
|
||||
|
||||
Future<void> pauseDownload(String taskId) async {
|
||||
final task = _tasks[taskId];
|
||||
if (task != null) {
|
||||
await FileDownloader().pause(task);
|
||||
await _updateRecords();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resumeDownload(String taskId) async {
|
||||
final task = _tasks[taskId];
|
||||
if (task != null) {
|
||||
await FileDownloader().resume(task);
|
||||
await _updateRecords();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelDownload(String taskId) async {
|
||||
final task = _tasks[taskId];
|
||||
if (task != null) {
|
||||
await FileDownloader().cancelTaskWithId(taskId);
|
||||
_tasks.remove(taskId);
|
||||
await _updateRecords();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _recordsController.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DownloadService {
|
||||
static final DownloadService _instance = DownloadService._internal();
|
||||
static DownloadService get instance => _instance;
|
||||
|
||||
final FileDownloader _downloader = FileDownloader();
|
||||
final _updateController = StreamController<TaskUpdate>.broadcast();
|
||||
|
||||
Stream<TaskUpdate> get updates => _updateController.stream;
|
||||
StreamSubscription? _downloadSubscription;
|
||||
|
||||
DownloadService._internal();
|
||||
|
||||
Future<void> initialize() async {
|
||||
await _downloader.trackTasks();
|
||||
|
||||
// Subscribe to FileDownloader updates and broadcast them
|
||||
_downloadSubscription = _downloader.updates.listen(
|
||||
(update) => _updateController.add(update),
|
||||
onError: (error) => _updateController.addError(error),
|
||||
);
|
||||
|
||||
FileDownloader().configureNotification(
|
||||
running: const TaskNotification('Downloading', 'File: {filename}'),
|
||||
complete: const TaskNotification('Download finished', 'File: {filename}'),
|
||||
progressBar: true,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_downloadSubscription?.cancel();
|
||||
_updateController.close();
|
||||
}
|
||||
|
||||
Future<List<TaskRecord>> getAllDownloads() async {
|
||||
return await _downloader.database.allRecords();
|
||||
}
|
||||
|
||||
Future<TaskRecord?> getById(String taskId) async {
|
||||
return await _downloader.database.recordForId(taskId);
|
||||
}
|
||||
|
||||
Future<void> pauseDownload(DownloadTask task) async {
|
||||
await _downloader.pause(task);
|
||||
}
|
||||
|
||||
Future<void> resumeDownload(DownloadTask task) async {
|
||||
await _downloader.resume(task);
|
||||
}
|
||||
|
||||
Future<void> deleteDownload(String taskId) async {
|
||||
await _downloader.database.deleteRecordWithId(taskId);
|
||||
}
|
||||
|
||||
Future<void> startDownload(DownloadTask task) async {
|
||||
const permissionType = PermissionType.notifications;
|
||||
var status = await FileDownloader().permissions.status(permissionType);
|
||||
if (status != PermissionStatus.granted) {
|
||||
if (await FileDownloader()
|
||||
.permissions
|
||||
.shouldShowRationale(permissionType)) {}
|
||||
status = await FileDownloader().permissions.request(permissionType);
|
||||
debugPrint('Permission for $permissionType was $status');
|
||||
}
|
||||
|
||||
await _downloader.enqueue(task);
|
||||
}
|
||||
}
|
||||
348
lib/features/explore/containers/explore_addon.dart
Normal file
348
lib/features/explore/containers/explore_addon.dart
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import 'package:cached_query/cached_query.dart';
|
||||
import 'package:flex_color_picker/flex_color_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
|
||||
import 'package:madari_client/features/streamio_addons/models/stremio_base_types.dart';
|
||||
import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart';
|
||||
import 'package:madari_client/utils/array-extension.dart';
|
||||
|
||||
import '../../widgetter/plugins/stremio/widgets/catalog_grid_full.dart';
|
||||
import '../../widgetter/plugins/stremio/widgets/error_card.dart';
|
||||
|
||||
final _logger = Logger('ExploreAddon');
|
||||
|
||||
class ExploreAddon extends StatefulWidget {
|
||||
final List<StremioManifest> data;
|
||||
const ExploreAddon({
|
||||
super.key,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ExploreAddon> createState() => _ExploreAddonState();
|
||||
}
|
||||
|
||||
class _ExploreAddonState extends State<ExploreAddon> {
|
||||
String? selectedType;
|
||||
String? selectedId;
|
||||
String? selectedGenre;
|
||||
StremioManifest? selectedAddon;
|
||||
static const int pageSize = 50;
|
||||
final service = StremioAddonService.instance;
|
||||
|
||||
InfiniteQuery<List<Meta>, int>? _query;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
setFirstThing();
|
||||
setOptionValues();
|
||||
setQuery();
|
||||
}
|
||||
|
||||
setQuery() {
|
||||
_query = buildQuery();
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
String get queryKey {
|
||||
return "explorer_page_${selectedType}_${selectedId}_$selectedGenre";
|
||||
}
|
||||
|
||||
InfiniteQuery<List<Meta>, int> buildQuery() {
|
||||
return InfiniteQuery(
|
||||
key: queryKey,
|
||||
config: QueryConfig(
|
||||
cacheDuration: const Duration(days: 30),
|
||||
refetchDuration: const Duration(hours: 8),
|
||||
),
|
||||
getNextArg: (state) {
|
||||
final lastPage = state.lastPage;
|
||||
if (lastPage == null) return 1;
|
||||
if (lastPage.length < pageSize) return null;
|
||||
return state.length + 1;
|
||||
},
|
||||
queryFn: (page) async {
|
||||
_logger.info('Fetching catalog for page: $page');
|
||||
try {
|
||||
final addonManifest = await service
|
||||
.validateManifest(selectedAddon!.manifestUrl!)
|
||||
.queryFn();
|
||||
|
||||
List<ConnectionFilterItem> items = [];
|
||||
|
||||
if (selectedGenre != null) {
|
||||
items.add(
|
||||
ConnectionFilterItem(
|
||||
title: "genre",
|
||||
value: selectedGenre,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return service.getCatalog(
|
||||
addonManifest,
|
||||
selectedType!,
|
||||
selectedId!,
|
||||
page - 1,
|
||||
items,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_logger.severe('Error fetching catalog: $e', e, stack);
|
||||
throw Exception('Failed to fetch catalog');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setFirstThing() {
|
||||
final Set<String> genres = {};
|
||||
|
||||
StremioManifest? selectedAddon;
|
||||
|
||||
for (final item in widget.data) {
|
||||
for (final value in item.catalogs!) {
|
||||
selectedType ??= value.type;
|
||||
|
||||
selectedAddon ??= item;
|
||||
|
||||
if (selectedType == value.type) {
|
||||
selectedId ??= value.id;
|
||||
selectedAddon = item;
|
||||
}
|
||||
|
||||
if (selectedType == value.type && selectedId == value.id) {
|
||||
final extra = value.extra?.firstWhereOrNull((extra) {
|
||||
return extra.name == "genre";
|
||||
});
|
||||
|
||||
if (extra != null && extra.options?.isNotEmpty == true) {
|
||||
for (final option in extra.options!) {
|
||||
selectedGenre ??= option;
|
||||
|
||||
selectedAddon = item;
|
||||
|
||||
genres.add(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedAddon = selectedAddon;
|
||||
|
||||
this.genres = genres.toList();
|
||||
}
|
||||
|
||||
setOptionValues() {
|
||||
final Set<String> types = {};
|
||||
|
||||
for (final item in widget.data) {
|
||||
for (final value in item.catalogs!) {
|
||||
if (value.type != selectedType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
types.add(value.id);
|
||||
}
|
||||
}
|
||||
|
||||
categories = types.toList();
|
||||
}
|
||||
|
||||
List<String> get types {
|
||||
final Set<String> allTypes = {};
|
||||
|
||||
for (final item in widget.data) {
|
||||
if (item.catalogs == null) {
|
||||
continue;
|
||||
}
|
||||
for (final value in item.catalogs!) {
|
||||
allTypes.add(value.type);
|
||||
}
|
||||
}
|
||||
|
||||
return allTypes.toList();
|
||||
}
|
||||
|
||||
List<String> categories = [];
|
||||
List<String> genres = [];
|
||||
|
||||
void _showSelectionSheet(
|
||||
List<String> items,
|
||||
String title,
|
||||
String current,
|
||||
Function(String) onSelect, {
|
||||
List<String> resetTypes = const [],
|
||||
}) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
title: Text(
|
||||
items[index]
|
||||
.replaceAll(".", " ")
|
||||
.split(" ")
|
||||
.map((item) => item.capitalize)
|
||||
.join(" "),
|
||||
),
|
||||
selected: items[index] == current,
|
||||
trailing: items[index] == current
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).highlightColor,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
onSelect(items[index]);
|
||||
|
||||
if (resetTypes.contains('categories')) {
|
||||
selectedId = null;
|
||||
}
|
||||
|
||||
if (resetTypes.contains('genres')) {
|
||||
selectedGenre = null;
|
||||
}
|
||||
setFirstThing();
|
||||
setOptionValues();
|
||||
|
||||
setQuery();
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (selectedId == null || selectedType == null) {
|
||||
return const Scaffold(
|
||||
body: ErrorCard(error: "No addon with support for catalog"),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
selected: true,
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
selectedType!
|
||||
.replaceAll(".", " ")
|
||||
.split(" ")
|
||||
.map((item) => item.capitalize)
|
||||
.join(" "),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, size: 18)
|
||||
],
|
||||
),
|
||||
onSelected: (_) => _showSelectionSheet(
|
||||
types,
|
||||
'Select Type',
|
||||
selectedType!,
|
||||
(value) => setState(() => selectedType = value),
|
||||
resetTypes: [
|
||||
'categories',
|
||||
'genres',
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
selected: true,
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
selectedId!
|
||||
.replaceAll(".", " ")
|
||||
.split(" ")
|
||||
.map((item) => item.capitalize)
|
||||
.join(" "),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, size: 18)
|
||||
],
|
||||
),
|
||||
onSelected: (_) => _showSelectionSheet(
|
||||
categories,
|
||||
'Select Category',
|
||||
selectedId!,
|
||||
(value) => setState(() => selectedId = value),
|
||||
resetTypes: [
|
||||
'genres',
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (genres.isNotEmpty)
|
||||
FilterChip(
|
||||
selected: selectedGenre != null,
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
(selectedGenre ?? 'Genre')
|
||||
.replaceAll(".", " ")
|
||||
.split(" ")
|
||||
.map((item) => item.capitalize)
|
||||
.join(" "),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, size: 18)
|
||||
],
|
||||
),
|
||||
onSelected: (_) => _showSelectionSheet(
|
||||
genres,
|
||||
'Select Genre',
|
||||
selectedGenre ?? '',
|
||||
(value) => setState(() => selectedGenre = value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _query != null
|
||||
? CatalogFullView(
|
||||
initialItems: const [],
|
||||
prefix: "explore",
|
||||
query: buildQuery(),
|
||||
key: ValueKey(queryKey),
|
||||
)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
lib/features/explore/pages/explore.page.dart
Normal file
74
lib/features/explore/pages/explore.page.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import 'package:cached_query_flutter/cached_query_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
|
||||
import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart';
|
||||
import 'package:madari_client/features/widgetter/plugins/stremio/widgets/error_card.dart';
|
||||
|
||||
import '../../streamio_addons/models/stremio_base_types.dart';
|
||||
import '../containers/explore_addon.dart';
|
||||
|
||||
class ExplorePage extends StatefulWidget {
|
||||
const ExplorePage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ExplorePage> createState() => _ExplorePageState();
|
||||
}
|
||||
|
||||
class _ExplorePageState extends State<ExplorePage> {
|
||||
late Query<List<StremioManifest>> _query;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
setQuery();
|
||||
}
|
||||
|
||||
void setQuery() {
|
||||
_query = Query(
|
||||
key: "addons",
|
||||
queryFn: () async {
|
||||
final result = StremioAddonService.instance;
|
||||
|
||||
return await result
|
||||
.getInstalledAddons(
|
||||
enabledOnly: true,
|
||||
)
|
||||
.queryFn();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const Icon(Icons.explore_outlined),
|
||||
title: const Text("Explore"),
|
||||
),
|
||||
body: QueryBuilder(
|
||||
builder: (context, state) {
|
||||
if (state.status == QueryStatus.loading || state.data == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.data?.isEmpty == true) {
|
||||
return const ErrorCard(
|
||||
error: "No Addons found",
|
||||
title: "No addons are configured",
|
||||
);
|
||||
}
|
||||
|
||||
return ExploreAddon(
|
||||
data: state.data!,
|
||||
);
|
||||
},
|
||||
query: _query,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class FileItem {
|
||||
final String name;
|
||||
final bool isDirectory;
|
||||
final String? path;
|
||||
|
||||
FileItem({
|
||||
required this.name,
|
||||
required this.isDirectory,
|
||||
this.path,
|
||||
});
|
||||
}
|
||||
|
||||
class FilesManagerContainer extends StatefulWidget {
|
||||
final Future<List<FileItem>> Function(String? path) onLoadFiles;
|
||||
final Future<void> Function(String path, String name) onCreateFolder;
|
||||
|
||||
const FilesManagerContainer({
|
||||
super.key,
|
||||
required this.onLoadFiles,
|
||||
required this.onCreateFolder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FilesManagerContainer> createState() => _FilesManagerContainerState();
|
||||
}
|
||||
|
||||
class _FilesManagerContainerState extends State<FilesManagerContainer> {
|
||||
late Future<List<FileItem>> _filesFuture;
|
||||
String _currentPath = '';
|
||||
final List<String> _navigationStack = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadFiles();
|
||||
}
|
||||
|
||||
void _loadFiles() {
|
||||
_filesFuture = widget.onLoadFiles(_currentPath);
|
||||
}
|
||||
|
||||
void _navigateToFolder(String path) {
|
||||
setState(() {
|
||||
_navigationStack.add(_currentPath);
|
||||
_currentPath = path;
|
||||
_loadFiles();
|
||||
});
|
||||
}
|
||||
|
||||
bool _navigateBack() {
|
||||
if (_navigationStack.isNotEmpty) {
|
||||
setState(() {
|
||||
_currentPath = _navigationStack.removeLast();
|
||||
_loadFiles();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _createFolder() async {
|
||||
final controller = TextEditingController();
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Create New Folder'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Folder name',
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true && controller.text.isNotEmpty && mounted) {
|
||||
try {
|
||||
await widget.onCreateFolder(_currentPath, controller.text);
|
||||
setState(() {
|
||||
_loadFiles();
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to create folder: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
return !_navigateBack(); // Return true to exit app, false to stay
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: _navigationStack.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _navigateBack,
|
||||
)
|
||||
: null,
|
||||
title: Text(_currentPath.isEmpty ? 'Files' : _currentPath),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.create_new_folder),
|
||||
onPressed: _createFolder,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<FileItem>>(
|
||||
future: _filesFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('Error: ${snapshot.error}'),
|
||||
);
|
||||
}
|
||||
|
||||
final files = snapshot.data ?? [];
|
||||
|
||||
if (files.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No files found'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: files.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = files[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
file.isDirectory ? Icons.folder : Icons.insert_drive_file,
|
||||
color: file.isDirectory ? Colors.amber : Colors.blue,
|
||||
),
|
||||
title: Text(file.name),
|
||||
onTap: file.isDirectory
|
||||
? () => _navigateToFolder(file.path!)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,526 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:madari_client/features/connection/services/stremio_service.dart';
|
||||
import 'package:madari_client/features/connection/types/stremio.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../../engine/engine.dart';
|
||||
import '../../settings/types/connection.dart';
|
||||
|
||||
class CreateConnectionStep extends StatefulWidget {
|
||||
final void Function(Connection connection) onConnectionComplete;
|
||||
|
||||
const CreateConnectionStep({
|
||||
super.key,
|
||||
required this.onConnectionComplete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CreateConnectionStep> createState() => _CreateConnectionStepState();
|
||||
}
|
||||
|
||||
class _CreateConnectionStepState extends State<CreateConnectionStep> {
|
||||
final PocketBase pb = AppEngine.engine.pb;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _urlController = TextEditingController();
|
||||
final _nameController = TextEditingController(
|
||||
text: "Stremio Addons",
|
||||
);
|
||||
Connection? _existingConnection;
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
final List<Map<String, dynamic>> _addons = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
loadExistingConnection();
|
||||
}
|
||||
|
||||
loadExistingConnection() async {
|
||||
try {
|
||||
final existingConnection =
|
||||
await pb.collection("connection").getFirstListItem(
|
||||
"type.type = 'stremio_addons'",
|
||||
);
|
||||
|
||||
final connection = Connection.fromRecord(existingConnection);
|
||||
|
||||
_nameController.text = connection.title;
|
||||
final config = connection.config;
|
||||
|
||||
if (config['addons'] != null) {
|
||||
for (var url in config['addons']) {
|
||||
try {
|
||||
await _validateAddonUrl(url);
|
||||
} catch (e) {
|
||||
print("Failed to load addon");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_existingConnection = connection;
|
||||
} catch (e) {
|
||||
if (e is! ClientException) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _validateAddonUrl(String url_) async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final url = url_.replaceFirst("stremio://", "https://");
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
url.replaceFirst("stremio://", "https://"),
|
||||
),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final manifest = json.decode(response.body);
|
||||
|
||||
final _manifest = StremioManifest.fromJson(manifest);
|
||||
|
||||
if (manifest['name'] == null || manifest['id'] == null) {
|
||||
throw 'Invalid addon manifest';
|
||||
}
|
||||
|
||||
if (_addons.any((addon) => addon['url'] == url)) {
|
||||
throw 'Addon already added to the list';
|
||||
}
|
||||
|
||||
List<String> supportedTypes = [];
|
||||
|
||||
_manifest.resources?.forEach((item) {
|
||||
supportedTypes.add(item.name);
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_addons.add({
|
||||
'name': _manifest.name,
|
||||
'icon': manifest['logo'] ?? manifest['icon'],
|
||||
'url': url,
|
||||
'addons': manifest,
|
||||
'manifestParsed': _manifest,
|
||||
'types': supportedTypes,
|
||||
});
|
||||
_urlController.clear();
|
||||
});
|
||||
} else {
|
||||
throw 'Failed to fetch addon manifest';
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Invalid addon URL: ${e.toString()}';
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> showAddonWarningDialog(
|
||||
BuildContext context, {
|
||||
required bool isMeta,
|
||||
required bool isAddon,
|
||||
}) async {
|
||||
bool continueAnyway = false;
|
||||
|
||||
if (isMeta && isAddon) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Warning!'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMeta || !isAddon)
|
||||
const Text(
|
||||
'You are missing the following addons:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
if (!isMeta) const Text('🔴 Meta Addon'),
|
||||
if (!isAddon) const Text('🔴 Streaming Addon'),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Continuing without these addons may limit functionality. Are you sure you want to proceed?',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// User chooses to continue anyway
|
||||
Navigator.of(context).pop();
|
||||
continueAnyway = true;
|
||||
},
|
||||
child: const Text('CONTINUE ANYWAY'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// User chooses to add addon
|
||||
Navigator.of(context).pop();
|
||||
continueAnyway = false;
|
||||
},
|
||||
child: const Text('ADD ADDON'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return continueAnyway;
|
||||
}
|
||||
|
||||
Future<void> _saveConnection() async {
|
||||
if (!_formKey.currentState!.validate() || _addons.isEmpty) return;
|
||||
|
||||
bool hasMeta = false;
|
||||
bool hasStream = false;
|
||||
|
||||
for (final item in _addons) {
|
||||
final manifest = item['manifestParsed'] as StremioManifest;
|
||||
|
||||
if (manifest.resources == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final resource in manifest.resources!) {
|
||||
if (resource.name == "meta") {
|
||||
hasMeta = true;
|
||||
}
|
||||
|
||||
if (resource.name == "stream") {
|
||||
hasStream = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final result = await showAddonWarningDialog(
|
||||
context,
|
||||
isAddon: hasStream,
|
||||
isMeta: hasMeta,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final connectionType =
|
||||
await pb.collection("connection_type").getFirstListItem(
|
||||
"type = \"stremio_addons\"",
|
||||
);
|
||||
|
||||
final body = {
|
||||
'title': _nameController.text,
|
||||
'user': pb.authStore.record!.id,
|
||||
'type': connectionType.id,
|
||||
'config': jsonEncode({
|
||||
'addons': _addons.map((item) => item['url']).toList(),
|
||||
}),
|
||||
};
|
||||
|
||||
if (_existingConnection != null) {
|
||||
// Update existing connection
|
||||
await pb
|
||||
.collection('connection')
|
||||
.update(_existingConnection!.id, body: body);
|
||||
} else {
|
||||
// Create new connection
|
||||
final result = await pb.collection('connection').create(body: body);
|
||||
|
||||
_existingConnection = Connection.fromRecord(result);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Connection saved successfully"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
widget.onConnectionComplete(
|
||||
Connection(
|
||||
title: _nameController.text,
|
||||
id: _existingConnection!.id ?? '',
|
||||
config: jsonEncode({
|
||||
'addons': _addons.map((item) => item['url']).toList(),
|
||||
}),
|
||||
type: 'stremio_addons',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = "Error: ${e.toString()}";
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, String> _items = {
|
||||
"Cinemeta": "https://v3-cinemeta.strem.io/manifest.json",
|
||||
"Watchhub": "https://watchhub.strem.io/manifest.json",
|
||||
"Subtitles": "https://opensubtitles-v3.strem.io/manifest.json",
|
||||
};
|
||||
|
||||
void _removeAddon(int index) {
|
||||
setState(() {
|
||||
_addons.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
void _reorderAddon(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _addons.removeAt(oldIndex);
|
||||
_addons.insert(newIndex, item);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize:
|
||||
MainAxisSize.min, // Add this to shrink-wrap the Column
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Connection Name',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a connection name';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Addon URL',
|
||||
hintText: 'https://example.com/manifest.json',
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _validateAddonUrl(_urlController.text),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (_addons.isEmpty) {
|
||||
return 'Please add at least one addon';
|
||||
}
|
||||
if (value != null && value.isNotEmpty) {
|
||||
try {
|
||||
final uri = Uri.parse(value);
|
||||
if (!uri.isScheme('http') && !uri.isScheme('https')) {
|
||||
return 'Please enter a valid HTTP/HTTPS URL';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView.builder(
|
||||
itemCount: _items.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
child: ActionChip(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
label: Text(_items.keys.toList()[index]),
|
||||
avatar: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
_validateAddonUrl(_items.values.toList()[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
if (_errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_addons.isNotEmpty) ...[
|
||||
const Text(
|
||||
'Added Addons:',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _addons.length,
|
||||
onReorder: _reorderAddon,
|
||||
itemBuilder: (context, index) {
|
||||
final addon = _addons[index];
|
||||
final name = utf8.decode(
|
||||
(addon['name'] ?? 'Unknown Addon').runes.toList(),
|
||||
);
|
||||
|
||||
return Card(
|
||||
key: Key('$index'),
|
||||
margin: EdgeInsets.only(
|
||||
bottom: index + 1 != _addons.length ? 10 : 0,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: addon['icon'] != null
|
||||
? Image.network(
|
||||
addon['icon'],
|
||||
width: 40,
|
||||
height: 40,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Icon(Icons.extension),
|
||||
)
|
||||
: const Icon(Icons.extension, size: 40),
|
||||
title: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
addon['url'],
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (int i = 0;
|
||||
i < addon['types'].length;
|
||||
i++)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(right: 4),
|
||||
child: RawChip(
|
||||
padding: EdgeInsets.zero,
|
||||
label: Text(
|
||||
(addon['types'][i] as String)
|
||||
.capitalize(),
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline,
|
||||
color: Colors.red),
|
||||
onPressed: () => _removeAddon(index),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 12.0,
|
||||
top: 12.0,
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: _addons.isEmpty ? null : _saveConnection,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white70,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
child: Text(
|
||||
'Next',
|
||||
style: GoogleFonts.exo2().copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:madari_client/features/connection/containers/auto_import.dart';
|
||||
|
||||
import '../../settings/types/connection.dart';
|
||||
import 'create_connection.dart';
|
||||
|
||||
class GettingStartedScreen extends StatefulWidget {
|
||||
final VoidCallback onCallback;
|
||||
final bool hasBackground;
|
||||
|
||||
const GettingStartedScreen({
|
||||
super.key,
|
||||
required this.onCallback,
|
||||
this.hasBackground = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GettingStartedScreen> createState() => _GettingStartedScreenState();
|
||||
}
|
||||
|
||||
class _GettingStartedScreenState extends State<GettingStartedScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
late AnimationController _animationController;
|
||||
Connection? _connection;
|
||||
|
||||
late final List<OnboardingStep> steps = [
|
||||
OnboardingStep(
|
||||
key: 'create_connection',
|
||||
title: 'Setup Connection',
|
||||
description: 'Configure your Stremio addons',
|
||||
icon: Icons.link_rounded,
|
||||
gradientColors: [Colors.purple.shade800, Colors.blue.shade900],
|
||||
),
|
||||
OnboardingStep(
|
||||
key: 'create_library',
|
||||
title: 'Create Library',
|
||||
description: 'Organize your data into libraries for better management',
|
||||
icon: Icons.library_books_rounded,
|
||||
gradientColors: [Colors.blue.shade900, Colors.teal.shade800],
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _nextPage() {
|
||||
if (_currentPage < steps.length - 1) {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _previousPage() {
|
||||
if (_currentPage > 0) {
|
||||
_pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.of(context).size.width > 800;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (widget.hasBackground)
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: steps[_currentPage].gradientColors,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: isDesktop ? 1200 : double.infinity,
|
||||
maxHeight: 800,
|
||||
),
|
||||
child: Card(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? 48.0 : 0,
|
||||
vertical: isDesktop ? 32.0 : 0,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
color: isDesktop ? null : Colors.transparent,
|
||||
elevation: 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Stack(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() => _currentPage = index);
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
},
|
||||
itemCount: steps.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildPage(steps[index], index);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPage(
|
||||
OnboardingStep step,
|
||||
int index,
|
||||
) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 22),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
if (index != 0)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
_previousPage();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.arrow_back,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 6,
|
||||
),
|
||||
Text(
|
||||
step.title,
|
||||
textAlign: TextAlign.start,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
child: Text(
|
||||
step.description,
|
||||
textAlign: TextAlign.start,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 16,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 0),
|
||||
if (step.key == 'create_library')
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height *
|
||||
0.6, // Adjust this value as needed
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AutoImport(
|
||||
item: _connection!,
|
||||
onImport: () {
|
||||
widget.onCallback();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (step.key == 'create_connection')
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
child: CreateConnectionStep(
|
||||
onConnectionComplete: (Connection connection) {
|
||||
_connection = connection;
|
||||
_nextPage();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OnboardingStep {
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final List<Color> gradientColors;
|
||||
final String key;
|
||||
|
||||
OnboardingStep({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
required this.gradientColors,
|
||||
required this.key,
|
||||
});
|
||||
}
|
||||
189
lib/features/home/pages/home_page.dart
Normal file
189
lib/features/home/pages/home_page.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:madari_client/features/settings/service/selected_profile.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../widgetter/plugin_layout.dart';
|
||||
import '../../widgetter/state/widget_state_provider.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
final bool hasSearch;
|
||||
final bool isExplore;
|
||||
|
||||
const HomePage({
|
||||
super.key,
|
||||
this.hasSearch = false,
|
||||
this.isExplore = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
final _state = GlobalKey<LayoutManagerState>();
|
||||
|
||||
late StreamSubscription<String?> _selectedProfile;
|
||||
|
||||
Widget _buildLogo() {
|
||||
return Image.asset(
|
||||
'assets/icon/icon_mini.png',
|
||||
height: 32,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_selectedProfile =
|
||||
SelectedProfileService.instance.selectedProfileStream.listen((data) {
|
||||
_state.currentState?.refresh();
|
||||
});
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_selectedProfile.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
title: Row(
|
||||
children: [
|
||||
_buildLogo(),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
const Text('Madari'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (UniversalPlatform.isDesktop)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
_state.currentState?.refresh();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context.push("/downloads");
|
||||
},
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LayoutManager(
|
||||
key: _state,
|
||||
hasSearch: widget.hasSearch,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchBox extends StatefulWidget {
|
||||
final String? hintText;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final double? height;
|
||||
|
||||
const SearchBox({
|
||||
super.key,
|
||||
this.hintText,
|
||||
this.padding,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SearchBox> createState() => _SearchBoxState();
|
||||
}
|
||||
|
||||
class _SearchBoxState extends State<SearchBox> {
|
||||
Timer? _debounce;
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final provider = context.read<StateProvider>();
|
||||
_controller.text = provider.search;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value, StateProvider provider) {
|
||||
if (_debounce?.isActive ?? false) _debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 800), () {
|
||||
provider.setSearch(value);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSearch(StateProvider provider) {
|
||||
_controller.clear();
|
||||
provider.setSearch('');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: widget.padding ??
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Consumer<StateProvider>(
|
||||
builder: (context, provider, _) {
|
||||
return SearchBar(
|
||||
controller: _controller,
|
||||
hintText: widget.hintText ?? 'Search...',
|
||||
padding: WidgetStateProperty.all(
|
||||
const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
onChanged: (value) => _onSearchChanged(value, provider),
|
||||
leading: Icon(
|
||||
Icons.search,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
trailing: [
|
||||
if (provider.search.trim() != "")
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () => _clearSearch(provider),
|
||||
),
|
||||
],
|
||||
elevation: WidgetStateProperty.all(0),
|
||||
backgroundColor: WidgetStateProperty.all(
|
||||
colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
),
|
||||
constraints: BoxConstraints.tightFor(
|
||||
height: widget.height ?? 46,
|
||||
),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue